10. 打磨与发布
2026/4/14大约 9 分钟
10. 伏魔记——打磨与发布
10.1 什么是"打磨"?
想象你做了一桌子菜。菜都熟了,但还需要摆盘、撒葱花、擦桌子、准备碗筷——这些"最后的修饰"就是打磨(Polish)。在游戏开发中,打磨指的是在核心功能完成之后,添加各种"锦上添花"的细节,让游戏体验更加完善。
| 打磨项目 | 说明 | 生活比喻 |
|---|---|---|
| 多结局系统 | 不同的选择导致不同的结局 | 同一部电影有不同版本 |
| 过场动画 | 剧情演出时的动画和对话 | 电影的转场 |
| 存档系统 | 多槽位存档/读档 | 游戏的"存档点" |
| 游戏平衡 | 确保难度合适、数值合理 | 调味——不能太咸也不能太淡 |
| 导出发布 | 把游戏打包成可执行文件 | 把菜端上桌 |
10.2 多结局系统
多结局让玩家的选择变得有意义——你帮助了某个NPC,可能就会解锁好的结局;你做了坏事,可能会走向坏结局。
结局判定机制
我们用游戏标记(Flags)来记录玩家在游戏过程中做出的关键选择。游戏结束时,根据这些标记的组合来决定最终结局。
C
using Godot;
/// <summary>
/// 结局类型枚举
/// </summary>
public enum EndingType
{
Perfect, // 完美结局——所有任务完成,所有NPC存活
Good, // 好结局——主线完成,大部分支线完成
Normal, // 普通结局——仅主线完成
Bad // 坏结局——关键NPC死亡
}
/// <summary>
/// 结局管理器——根据玩家的选择判定最终结局
/// </summary>
public partial class EndingManager : Node
{
/// <summary>
/// 判定结局
/// </summary>
public EndingType DetermineEnding()
{
var flags = GameManager.Instance;
// 坏结局:关键NPC死亡
if (flags.GetFlag("elder_dead"))
return EndingType.Bad;
// 完美结局:完成所有主线+支线+救出所有人
if (flags.GetFlag("saved_all_npcs")
&& flags.GetFlag("found_hidden_treasure")
&& flags.GetFlag("defeated_secret_boss"))
{
return EndingType.Perfect;
}
// 好结局:主线完成+大部分支线完成
if (flags.GetFlag("main_quest_complete")
&& flags.GetFlag("side_quests_70_percent"))
{
return EndingType.Good;
}
// 普通结局:仅主线完成
if (flags.GetFlag("main_quest_complete"))
return EndingType.Normal;
return EndingType.Normal;
}
/// <summary>
/// 显示结局画面
/// </summary>
public void ShowEnding(EndingType ending)
{
string title = ending switch
{
EndingType.Perfect => "完美结局 —— 众生安宁",
EndingType.Good => "好结局 —— 天下太平",
EndingType.Normal => "普通结局 —— 归隐山林",
EndingType.Bad => "坏结局 —— 悲剧收场",
_ => "结局"
};
GD.Print($"结局: {title}");
// 播放结局动画和字幕...
// 显示制作人员名单...
}
}GDScript
## 结局类型枚举
enum EndingType {
PERFECT, ## 完美结局
GOOD, ## 好结局
NORMAL, ## 普通结局
BAD ## 坏结局
}
extends Node
## 结局管理器——根据玩家的选择判定最终结局
## 判定结局
func determine_ending() -> EndingType:
# 坏结局:关键NPC死亡
if GameManager.get_flag("elder_dead"):
return EndingType.BAD
# 完美结局:完成所有主线+支线+救出所有人
if GameManager.get_flag("saved_all_npcs") \
and GameManager.get_flag("found_hidden_treasure") \
and GameManager.get_flag("defeated_secret_boss"):
return EndingType.PERFECT
# 好结局:主线完成+大部分支线完成
if GameManager.get_flag("main_quest_complete") \
and GameManager.get_flag("side_quests_70_percent"):
return EndingType.GOOD
# 普通结局:仅主线完成
if GameManager.get_flag("main_quest_complete"):
return EndingType.NORMAL
return EndingType.NORMAL
## 显示结局画面
func show_ending(ending: EndingType) -> void:
var title: String
match ending:
EndingType.PERFECT:
title = "完美结局 —— 众生安宁"
EndingType.GOOD:
title = "好结局 —— 天下太平"
EndingType.NORMAL:
title = "普通结局 —— 归隐山林"
EndingType.BAD:
title = "坏结局 —— 悲剧收场"
_:
title = "结局"
print("结局: ", title)结局判定逻辑表
| 条件标记 | 完美结局 | 好结局 | 普通结局 | 坏结局 |
|---|---|---|---|---|
| 主线完成 | 需要 | 需要 | 需要 | 不需要 |
| 70%支线完成 | 需要 | 需要 | 不需要 | 不需要 |
| 救出所有NPC | 需要 | 不需要 | 不需要 | 不需要 |
| 找到隐藏宝物 | 需要 | 不需要 | 不需要 | 不需要 |
| 击败隐藏Boss | 需要 | 不需要 | 不需要 | 不需要 |
| 关键NPC死亡 | 不允许 | 不允许 | 不允许 | 触发 |
10.3 过场动画
过场动画(Cutscene)是在剧情关键节点播放的动画演出。我们用一种简单的方式来实现——序列动画:在指定的时间点,控制角色移动、显示文字、播放音效。
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 过场动画指令——一条动画指令
/// </summary>
public class CutsceneCommand
{
public enum CommandType
{
MoveCharacter, // 移动角色到指定位置
ShowDialogue, // 显示对话
Wait, // 等待指定秒数
PlayAnimation, // 播放角色动画
FadeIn, // 淡入
FadeOut, // 淡出
PlayMusic, // 播放音乐
SetFlag, // 设置游戏标记
ScreenShake // 屏幕震动
}
public CommandType Type { get; set; }
public string TargetId { get; set; } // 目标角色/NPC的ID
public Vector2 Position { get; set; } // 移动目标位置
public float Duration { get; set; } // 持续时间
public string Text { get; set; } // 对话文字
public string AnimationName { get; set; } // 动画名称
}
/// <summary>
/// 过场动画播放器
/// </summary>
public partial class CutscenePlayer : Node
{
private Queue<CutsceneCommand> _commands = new();
private bool _isPlaying = false;
public bool IsPlaying => _isPlaying;
/// <summary>
/// 开始播放过场动画
/// </summary>
public async void PlayCutscene(List<CutsceneCommand> commands)
{
if (_isPlaying) return;
_isPlaying = true;
GameManager.Instance.GameState = RpgGameState.Cutscene;
foreach (var cmd in commands)
{
await ExecuteCommand(cmd);
}
_isPlaying = false;
GameManager.Instance.GameState = RpgGameState.TownExplore;
}
/// <summary>
/// 执行一条动画指令
/// </summary>
private async Godot.Awaitable ExecuteCommand(CutsceneCommand cmd)
{
switch (cmd.Type)
{
case CutsceneCommand.CommandType.MoveCharacter:
await MoveCharacterAsync(cmd.TargetId, cmd.Position, cmd.Duration);
break;
case CutsceneCommand.CommandType.ShowDialogue:
await ShowDialogueAsync(cmd.Text, cmd.Duration);
break;
case CutsceneCommand.CommandType.Wait:
await ToSignal(GetTree().CreateTimer(cmd.Duration), "timeout");
break;
case CutsceneCommand.CommandType.FadeIn:
await FadeAsync(false, cmd.Duration);
break;
case CutsceneCommand.CommandType.FadeOut:
await FadeAsync(true, cmd.Duration);
break;
case CutsceneCommand.CommandType.SetFlag:
GameManager.Instance.SetFlag(cmd.TargetId, true);
break;
}
}
private async Godot.Awaitable MoveCharacterAsync(
string charId, Vector2 target, float duration)
{
var player = GetTree().CurrentScene?.FindChild(charId) as Node2D;
if (player == null) return;
var tween = CreateTween();
tween.TweenProperty(player, "global_position", target, duration);
await ToSignal(tween, "finished");
}
private async Godot.Awaitable ShowDialogueAsync(string text, float duration)
{
var dialogueBox = GetNode<Control>("%DialogueBox");
var label = dialogueBox.GetNode<Label>("TextLabel");
label.Text = text;
dialogueBox.Visible = true;
await ToSignal(GetTree().CreateTimer(duration), "timeout");
dialogueBox.Visible = false;
}
private async Godot.Awaitable FadeAsync(bool fadeOut, float duration)
{
var overlay = GetNode<ColorRect>("%TransitionOverlay");
var targetAlpha = fadeOut ? 1.0f : 0.0f;
var tween = CreateTween();
tween.TweenProperty(overlay, "color:a", targetAlpha, duration);
await ToSignal(tween, "finished");
}
}GDScript
## 过场动画指令类型
enum CommandType {
MOVE_CHARACTER, ## 移动角色
SHOW_DIALOGUE, ## 显示对话
WAIT, ## 等待
PLAY_ANIMATION, ## 播放动画
FADE_IN, ## 淡入
FADE_OUT, ## 淡出
PLAY_MUSIC, ## 播放音乐
SET_FLAG, ## 设置标记
SCREEN_SHAKE ## 屏幕震动
}
## 过场动画指令
class_name CutsceneCommand
var type: CommandType
var target_id: String
var position: Vector2
var duration: float
var text: String
var animation_name: String
extends Node
## 过场动画播放器
var _commands: Array[CutsceneCommand] = []
var _is_playing: bool = false
var is_playing: bool:
get: return _is_playing
## 开始播放过场动画
func play_cutscene(commands: Array[CutsceneCommand]) -> void:
if _is_playing:
return
_is_playing = true
GameManager.game_state = RpgGameState.CUTSCENE
for cmd in commands:
await _execute_command(cmd)
_is_playing = false
GameManager.game_state = RpgGameState.TOWN_EXPLORE
## 执行一条动画指令
func _execute_command(cmd: CutsceneCommand) -> void:
match cmd.type:
CommandType.MOVE_CHARACTER:
await _move_character_async(cmd.target_id, cmd.position, cmd.duration)
CommandType.SHOW_DIALOGUE:
await _show_dialogue_async(cmd.text, cmd.duration)
CommandType.WAIT:
await get_tree().create_timer(cmd.duration).timeout
CommandType.FADE_IN:
await _fade_async(false, cmd.duration)
CommandType.FADE_OUT:
await _fade_async(true, cmd.duration)
CommandType.SET_FLAG:
GameManager.set_flag(cmd.target_id, true)
func _move_character_async(char_id: String, target: Vector2, dur: float) -> void:
var player = get_tree().current_scene.find_child(char_id) as Node2D
if player == null:
return
var tween = create_tween()
tween.tween_property(player, "global_position", target, dur)
await tween.finished
func _show_dialogue_async(text: String, dur: float) -> void:
var dialogue_box = get_node("%DialogueBox")
var label = dialogue_box.get_node("TextLabel")
label.text = text
dialogue_box.visible = true
await get_tree().create_timer(dur).timeout
dialogue_box.visible = false
func _fade_async(fade_out: bool, dur: float) -> void:
var overlay = get_node("%TransitionOverlay")
var target_alpha: float = 1.0 if fade_out else 0.0
var tween = create_tween()
tween.tween_property(overlay, "color:a", target_alpha, dur)
await tween.finished10.4 存档系统
存档系统让玩家可以保存游戏进度,下次继续玩。
C
using Godot;
/// <summary>
/// 存档管理器——处理游戏进度的保存和加载
/// </summary>
public partial class SaveManager : Node
{
private const string SaveDir = "user://saves/";
/// <summary>
/// 保存游戏到指定槽位
/// </summary>
public void SaveGame(int slot)
{
// 确保存档目录存在
if (!DirAccess.DirExistsAbsolute(SaveDir))
{
DirAccess.MakeDirRecursiveAbsolute(SaveDir);
}
// 收集所有需要保存的数据
var saveData = new Godot.Collections.Dictionary
{
{ "slot", slot },
{ "timestamp", System.DateTime.Now.ToString() },
{ "play_time", GameManager.Instance.GetPlayTimeString() },
{ "game_data", GameManager.Instance.GetSaveData() },
{ "map_id", GameManager.Instance.CurrentMap },
{ "player_position", _getPlayerPosition() }
};
// 序列化为JSON并写入文件
string json = Json.Stringify(saveData, " ");
string filePath = SaveDir + $"save_{slot}.json";
var file = FileAccess.Open(filePath, FileAccess.ModeFlags.Write);
file.StoreString(json);
file.Close();
GD.Print($"游戏已保存到槽位 {slot}");
}
/// <summary>
/// 从指定槽位加载游戏
/// </summary>
public bool LoadGame(int slot)
{
string filePath = SaveDir + $"save_{slot}.json";
if (!FileAccess.FileExists(filePath))
{
GD.PrintErr($"存档文件不存在: 槽位 {slot}");
return false;
}
var file = FileAccess.Open(filePath, FileAccess.ModeFlags.Read);
string json = file.GetAsText();
file.Close();
var jsonParser = new Json()
{
Data = new Godot.Collections.Dictionary()
};
var parseResult = jsonParser.Parse(json);
if (parseResult != Error.Ok)
{
GD.PrintErr("存档数据解析失败!");
return false;
}
var saveData = (Godot.Collections.Dictionary)jsonParser.Data;
// 恢复游戏数据
GD.Print($"从槽位 {slot} 加载游戏");
return true;
}
/// <summary>
/// 检查指定槽位是否有存档
/// </summary>
public bool HasSave(int slot)
{
return FileAccess.FileExists(SaveDir + $"save_{slot}.json");
}
/// <summary>
/// 获取存档信息(用于显示在存档界面上)
/// </summary>
public Godot.Collections.Dictionary GetSaveInfo(int slot)
{
string filePath = SaveDir + $"save_{slot}.json";
if (!FileAccess.FileExists(filePath))
return null;
var file = FileAccess.Open(filePath, FileAccess.ModeFlags.Read);
string json = file.GetAsText();
file.Close();
var jsonParser = new Json();
jsonParser.Parse(json);
return (Godot.Collections.Dictionary)jsonParser.Data;
}
/// <summary>
/// 删除指定槽位的存档
/// </summary>
public void DeleteSave(int slot)
{
string filePath = SaveDir + $"save_{slot}.json";
if (FileAccess.FileExists(filePath))
{
DirAccess.RemoveAbsolute(filePath);
GD.Print($"已删除槽位 {slot} 的存档");
}
}
private Vector2 _getPlayerPosition()
{
var player = GetTree().CurrentScene?.FindChild("Player") as Node2D;
return player?.GlobalPosition ?? Vector2.Zero;
}
}GDScript
extends Node
## 存档管理器——处理游戏进度的保存和加载
const SAVE_DIR := "user://saves/"
## 保存游戏到指定槽位
func save_game(slot: int) -> void:
# 确保存档目录存在
if not DirAccess.dir_exists_absolute(SAVE_DIR):
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
# 收集所有需要保存的数据
var save_data := {
"slot": slot,
"timestamp": Time.get_datetime_string_from_system(),
"play_time": "01:23:45",
"game_data": GameManager.get_save_data(),
"map_id": GameManager.current_map,
"player_position": _get_player_position()
}
# 序列化为JSON并写入文件
var json := JSON.stringify(save_data, " ")
var file_path := SAVE_DIR + "save_%d.json" % slot
var file = FileAccess.open(file_path, FileAccess.WRITE)
file.store_string(json)
file.close()
print("游戏已保存到槽位 ", slot)
## 从指定槽位加载游戏
func load_game(slot: int) -> bool:
var file_path := SAVE_DIR + "save_%d.json" % slot
if not FileAccess.file_exists(file_path):
printerr("存档文件不存在: 槽位 ", slot)
return false
var file = FileAccess.open(file_path, FileAccess.READ)
var json := file.get_as_text()
file.close()
var json_result = JSON.new()
var parse_result = json_result.parse(json)
if parse_result != OK:
printerr("存档数据解析失败!")
return false
var save_data = json_result.data
print("从槽位 ", slot, " 加载游戏")
return true
## 检查指定槽位是否有存档
func has_save(slot: int) -> bool:
return FileAccess.file_exists(SAVE_DIR + "save_%d.json" % slot)
## 获取存档信息
func get_save_info(slot: int) -> Dictionary:
var file_path := SAVE_DIR + "save_%d.json" % slot
if not FileAccess.file_exists(file_path):
return {}
var file = FileAccess.open(file_path, FileAccess.READ)
var json := file.get_as_text()
file.close()
var json_result = JSON.new()
json_result.parse(json)
return json_result.data
## 删除指定槽位的存档
func delete_save(slot: int) -> void:
var file_path := SAVE_DIR + "save_%d.json" % slot
if FileAccess.file_exists(file_path):
DirAccess.remove_absolute(file_path)
print("已删除槽位 ", slot, " 的存档")
func _get_player_position() -> Vector2:
var player = get_tree().current_scene.find_child("Player") as Node2D
return player.global_position if player else Vector2.ZERO10.5 游戏导出
在Godot中导出游戏的步骤:
- 菜单 → 项目 → 导出(Project → Export)
- 添加目标平台:
- Windows Desktop:导出
.exe可执行文件 - Linux/X11:导出 Linux 版本
- macOS:导出
.app应用 - Android:导出 APK 安装包
- Web:导出 HTML5 网页版
- Windows Desktop:导出
- 配置导出选项(图标、名称等)
- 点击"导出项目"
导出前的检查清单
| 检查项 | 说明 |
|---|---|
| 所有场景能正常加载 | 没有缺失的资源引用 |
| 存档功能正常 | 保存→退出→加载→继续 |
| 音效文件存在 | 没有找不到的音频文件 |
| 输入映射完整 | 所有按键都已配置 |
| 窗口大小正确 | 在目标平台上显示正常 |
| 游戏能通关 | 从开始到结局的完整流程 |
| 没有调试信息 | 移除 GD.Print 或设为仅开发模式 |
10.6 本章小结
| 知识点 | 说明 |
|---|---|
| 多结局系统 | 根据游戏标记判定完美/好/普通/坏四种结局 |
| 过场动画 | 指令队列+异步执行,实现角色移动、对话、淡入淡出 |
| 存档系统 | JSON序列化+文件读写,支持多槽位 |
| 导出发布 | 配置导出选项,检查清单,打包为可执行文件 |
恭喜你!到这里,伏魔记的所有核心系统都已经完成了。从一个空项目到一个完整的RPG,你已经掌握了游戏开发中最重要的技能:把复杂的大问题拆解成一个个小系统,逐个击破。
继续加油,做出属于你自己的游戏吧!
