游戏代码规范

这里选取 杀戮尖塔2 的游戏解包内容, 用于借鉴其代码结构和目录架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
STS2SourceCode/
├── project.godot # Godot4 主工程配置, 导入后加载整个项目
├── .godot/ # Godot引擎缓存目录(导入资源生成)
├── src/ # 全部C#业务源代码, 游戏核心逻辑
│ ├── Core/ # 领域核心逻辑, 纯数据层, 与渲染解耦
│ │ ├── Models/ # 全部实体数据模型定义
│ │ │ ├── Cards/ # 卡牌基类、各类卡牌实现、卡牌升级、附魔逻辑
│ │ │ ├── Relics/ # 遗物基类、全部遗物效果定义
│ │ │ ├── Potions/ # 药水实体、药水使用逻辑
│ │ │ ├── Characters/ # 玩家角色基础属性、职业数据
│ │ │ ├── Monsters/ # 怪物模板、AI行为模板、怪物属性
│ │ │ ├── Buffs/ # 力量/减益状态、持续效果、状态标记
│ │ │ ├── Events/ # 地图随机事件数据模型、事件选项
│ │ │ ├── Enchantments/ # 二代新增卡牌附魔数据结构
│ │ │ └── Collections/ # 牌库、手牌、弃牌堆、墓地通用容器
│ │ ├── Combat/ # 战斗核心驱动
│ │ │ ├── CombatState.cs # 战斗全局状态容器(核心类)
│ │ │ ├── TurnSystem/ # 回合流程:回合开始、结束、玩家/怪物行动阶段
│ │ │ ├── CardActions/ # 卡牌动作执行管线、链式效果处理器
│ │ │ ├── DamageCalculation/ # 伤害公式、易伤、护盾、抗性计算逻辑
│ │ │ ├── Graveyard/ # 墓地独立模块, 死灵绑定者核心机制
│ │ │ └── CombatTriggers/ # 战斗各类钩子(出牌时、受伤时、死亡时等回调)
│ │ ├── Map/ # 爬塔大地图逻辑
│ │ │ ├── MapGenerator/ # 随机地图路径生成算法
│ │ │ ├── MapNodes/ # 节点类型:战斗、商店、篝火、事件、宝箱、休息点
│ │ │ ├── RouteLogic/ # 路线筛选、可达节点判定
│ │ │ └── MapReward/ # 节点通关奖励规则
│ │ ├── RNG/ # 随机数系统
│ │ │ ├── SeedManager.cs # 全局种子管理
│ │ │ ├── Xoshiro256.cs # 底层随机算法实现
│ │ │ └── RandomScope/ # 隔离随机域, 保证对局可回放
│ │ ├── Save/ # 存档持久化
│ │ │ ├── SaveData/ # 存档数据序列化模型
│ │ │ ├── SaveLoader.cs # 读档逻辑、数据兼容修复
│ │ │ └── SaveWriter.cs # 实时存档写入
│ │ ├── Multiplayer/ # 多人联机模块(二代原生支持)
│ │ │ ├── NetworkSync/ # 战斗状态同步器
│ │ │ ├── Lobby/ # 联机大厅、房间创建、玩家加入
│ │ │ ├── NetworkMessages/ # 网络通信数据包定义
│ │ │ └── CoopCombatRules/ # 多人合作战斗特殊规则
│ │ ├── Localization/ # 多语言本地化
│ │ │ ├── LocaleManager.cs # 语言切换管理器
│ │ │ ├── TextFormatter.cs # 文本变量插值、动态数值替换
│ │ │ └── LocaleKeys/ # 全局文本常量Key定义
│ │ └── Modding/ # 原生Mod加载框架
│ │ ├── ModLoader.cs # Mod Pck包加载器
│ │ ├── ModEvents.cs # 全局事件钩子系统
│ │ ├── ModApi/ # 对外暴露Mod开发接口
│ │ └── ModConfig/ # Mod配置读取与校验
│ ├── UI/ # 视图层, 所有界面交互逻辑(Godot节点绑定脚本)
│ │ ├── CardViews/
│ │ │ ├── CardRenderer.cs # 卡牌基础渲染
│ │ │ ├── CardTooltip.cs # 卡牌悬浮提示面板
│ │ │ ├── GraveyardView.cs # 墓地卡牌预览界面
│ │ │ └── CardUpgradePopup.cs # 卡牌升级弹窗
│ │ ├── CombatUI/
│ │ │ ├── PlayerStatusBar.cs # 血量、护盾、金币显示
│ │ │ ├── BuffIconPanel.cs # 力量/减益图标渲染
│ │ │ ├── DeckPileView.cs # 牌库、弃牌堆鼠标悬浮预览
│ │ │ └── MonsterUI.cs # 怪物血条、怪物状态面板
│ │ ├── RewardScreens/
│ │ │ ├── CardRewardScreen.cs # 战斗后卡牌三选一界面
│ │ │ ├── RelicRewardScreen.cs # 遗物奖励界面
│ │ │ ├── PotionRewardScreen.cs # 药水奖励界面
│ │ │ └── CampfireScreen.cs # 篝火休息、升级、移除卡牌界面
│ │ ├── ShopUI/
│ │ │ ├── ShopItemPanel.cs # 商店商品渲染
│ │ │ ├── PurgeCardScreen.cs # 商店移除卡牌界面
│ │ │ └── ShopRefreshLogic.cs # 商店商品刷新规则
│ │ ├── MapUI/
│ │ │ ├── MapNodeRenderer.cs # 地图节点图标绘制
│ │ │ ├── RouteLineRenderer.cs # 路线连接线渲染
│ │ │ └── MapNavigation.cs # 节点点击跳转逻辑
│ │ └── MenuUI/
│ │ ├── MainMenu.cs # 主菜单
│ │ ├── CharacterSelect.cs # 角色选择界面
│ │ ├── SettingsMenu.cs # 图像、音频、控制设置
│ │ ├── SaveSelectScreen.cs # 存档列表选择
│ │ └── MultiplayerLobbyUI.cs # 联机大厅界面
│ ├── Audio/ # 音频控制系统
│ │ ├── AudioManager.cs # 全局音频单例、音源池管理
│ │ ├── SFX/
│ │ │ ├── CombatSFX.cs # 战斗音效调度
│ │ │ ├── CardSFX.cs # 抽牌、打出、丢弃卡牌音效
│ │ │ └── UISFX.cs # 点击弹窗通用音效
│ │ └── Music/
│ │ ├── MusicTrackManager.cs # BGM切换控制器
│ │ ├── CombatMusic.cs # 战斗背景音乐
│ │ └── MapMusic.cs # 地图背景音乐
│ └── Animation/ # 动画、视觉特效逻辑
│ ├── CardAnimations/
│ │ ├── DrawCardAnim.cs # 抽牌动画
│ │ ├── PlayCardAnim.cs # 打出卡牌动画
│ │ └── DiscardCardAnim.cs # 卡牌丢弃动画
│ ├── CombatAnimations/
│ │ ├── MonsterAttackAnim.cs # 怪物攻击动画
│ │ ├── HitAnim.cs # 受伤抖动、受击动画
│ │ └── DeathAnim.cs # 怪物死亡退场动画
│ └── Vfx/
│ ├── ShieldVfx.cs # 护盾粒子特效
│ ├── ElementVfx.cs # 灼烧、冰冻、毒素特效
│ └── GenericParticleVfx.cs # 通用粒子特效控制器
├── scenes/ # Godot场景文件 *.tscn 节点树资源
│ ├── CombatScenes/
│ │ ├── CombatMain.tscn # 战斗主场景根节点
│ │ ├── PlayerHandContainer.tscn # 手牌容器节点
│ │ └── MonsterArena.tscn # 怪物战场容器
│ ├── MapScenes/
│ │ ├── MapMain.tscn # 爬塔地图主场景
│ │ ├── MapNode.tscn # 可复用地图节点预制体
│ │ └── MapBackground.tscn # 地图背景容器
│ ├── UIScenes/
│ │ ├── Popups/ # 各类弹窗预制体(奖励、提示、确认框)
│ │ ├── Tooltips/ # 悬浮提示预制体
│ │ └── OverlayUI.tscn # 全局顶层UI遮罩
│ └── CharacterScenes/
│ ├── CharacterPreview.tscn # 角色选择预览场景
│ └── CharacterBattleSprite.tscn # 战斗内角色立绘节点
├── images/ # 全部图片美术资源
│ ├── Cards/ # 卡牌原画、卡牌边框
│ ├── Monsters/ # 怪物立绘、怪物状态贴图
│ ├── Relics/ # 遗物图标素材
│ ├── Potions/ # 药水图标
│ ├── BuffIcons/ # 力量/减益图标
│ ├── UI/ # UI面板、按钮、边框素材
│ ├── MapNodes/ # 地图节点图标
│ └── Backgrounds/ # 战斗背景、地图背景图
├── localization/ # 本地化文本资源
│ ├── en/ # 英文原始文本
│ ├── zh/ # 中文翻译文本(逆向提取)
│ └── locale_metadata.json # 语言配置元数据
├── fonts/ # 游戏使用字体文件
│ ├── MainFont/ # 正文通用字体
│ ├── TitleFont/ # 标题粗体字体
│ └── TooltipFont/ # 提示框专用字体
├── shaders/ # GLSL着色器文件
│ ├── CardShaders/
│ │ ├── CardGlow.shader # 卡牌高亮发光效果
│ │ ├── CardGray.shader # 卡牌禁用灰度着色器
│ │ └── CardBorder.shader # 卡牌边框渲染
│ ├── DamageShaders/
│ │ └── HitFlash.shader # 受击白色闪烁效果
│ └── GeneralShaders/
│ ├── ParticleFade.shader # 粒子淡出着色器
│ └── UIMask.shader # UI遮罩裁剪着色器
├── audio/ # 音频资源(wav/ogg音效、音乐)
│ ├── sfx/ # 全部短音效
│ └── music/ # BGM音频文件
└── mods/ # 示例Mod目录(逆向工程自带测试目录)

内部的 project.godot 基础应用设置([application])

  • run/main_scene: 默认启动 res://scenes/game.tscn 场景节点

  • config/icon: 默认采用图片目录的 res://images/icon_1024.png(1024x1024像素)

  • config/windows_native_icon: 默认设置原生 window 系统图标 res://images/icon.ico

  • config/use_custom_user_dir=true: 自定义用户空间目录

  • config/custom_user_dir_name: 自定义用户空间目录名称, 一般采用游戏名(推荐采用全英文)

之后就是显示设置([display])

  • window/size/viewport_width=1920: 设置视窗宽度默认为 1920

  • window/size/viewport_height=1080: 设置视窗高度默认为 1080

  • window/size/initial_position_type=3: 设置初始的视窗类型为 Center of Other Screen

  • window/stretch/mode=canvas_items: 拉伸模式设置为 canvas_items

  • window/stretch/aspect=expand: 拉伸比例设置为 expand

自动加载全局功能类([autoload])

  • SentryInit(addons/sentry/SentryInit.gd)

    • 官方地址: https://docs.sentry.io/platforms/godot/
    • 第三方异常监控 SDK 初始化, 用于捕获游戏运行崩溃、异常日志上报
    • 常应用于移动设备日志上报的第三方 SDK, 该 SDK 并不是必须要集成
  • OneTimeInitialization(scenes/one_time_initialization.tscn)

    • 单次全局初始化工具, 用于基础环境校验、全局参数初始化、前置服务启动
    • 对应挂载 src/Core/Nodes/NOneTimeInitialization.cs 脚本
    • 该脚本负责初始化挂载 存档管理(SaveManager)/Mod管理(ModManager)
  • AssetLoader(scenes/asset_loader.tscn)

    • 全局资源加载管理器, 用于异步预加载、资源缓存、资源卸载管控
    • 对应挂载 src/Core/Nodes/NAssetLoader.cs 脚本
    • 该脚本用在后台队列会话场景加载, 采用 AssetLoadingSession 场景做会话管理
  • DevConsole(scenes/debug/dev_console.tscn)

    • 开发者调试控制台, 支持指令输入、运行时变量修改、调试指令执行
    • 编写特殊得系统指令功能, 用于游戏当中的调试和数值修改
  • CommandHistory(scenes/debug/command_history.tscn)

    • 配套调试控制台, 用于缓存输入命令、上下键历史回溯
  • MemoryMonitor(scenes/debug/memory_monitor.tscn)

    • 调试工具, 实时内存监控、资源泄漏检测、内存快照输出
  • FmodManager(addons/fmod/FmodManager.gd)

    • 官方地址: https://www.fmod.com/
    • FMOD 音频底层管理器, 全局音频总线、音效/BGM 调度、音频实例管理

这里面和游戏相关的就是 OneTimeInitialization/AssetLoader, 其他依靠附属的开发工具

游戏默认启动 scenes/game.tscn 场景来作为游戏根场景, 之后就是系统加载流程, 内部加载大量系统工具管理器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[根节点] Game (Control)              # 挂载 src/Core/Nodes/NGame.cs
├─ FmodBankLoader # 预加载FMOD音效资源包
├─ AudioManager # NAudioManager, 全局音频控制
├─ ScreenShake(NScreenShake) # 屏幕震动全局管理器
├─ HitStop(NHitStop) # 击中慢动作全局管理
├─ InputManager # 输入、手柄控制器、热键管理
├─ CursorManager # 鼠标光标样式切换
├─ RootSceneContainer # [重中之重]场景容器(scene_container.tscn)
│ # 所有业务场景(main_menu/map_main/combat_main)作为其子节点加载
├─ InspectionContainer # 卡牌详情悬浮面板容器(顶层UI)
├─ RemoteCursorContainer # 多人联机远程鼠标
├─ ReactionWheel/ReactionContainer # 互动轮盘
├─ HoverTipsContainer # 悬浮提示文本容器
├─ ModalContainer # 弹窗容器(商店、事件弹窗、确认框)
├─ FeedbackScreen # 反馈弹窗
├─ GameTransitionRect(NTransition) # 场景过渡器
│ ├─ GradientTransition
│ └─ SimpleTransition # 两种画面过渡动画(黑屏渐变/战斗专属过渡材质)
├─ MultiplayerTimeoutOverlay # 多人联机超时提示
├─ WorldEnvironment # 全局渲染环境、Glow泛光设置
├─ MuteInBackgroundHandler # 窗口失去焦点静音
└─ BackgroundModeHandler # 后台运行策略

也就是说, 整个游戏只有 Game 作为根节点, 其他场景都是作为子节点附加在之后运行

具体的信息可以参考如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
│
├── 资源加载层 (src/Core/Assets/) ──────────────────
│ ├── AssetCache.cs → 资源缓存池 (避免重复加载)
│ ├── AssetSets.cs → 资源集合定义 (按类型/场景分组加载)
│ ├── AssetLoadingSession.cs→ 异步资源加载会话 (236行, 完整加载流程)
│ ├── PreloadManager.cs → 预加载管理器
│ ├── AtlasManager.cs → 图集管理器 (SpriteAtlas 的统一接口)
│ └── TpSheet*.cs → TexturePacker 精灵表数据解析
│
├── 音频层 (src/Core/Audio/) ───────────────────────
│ ├── FmodSfx.cs → FMOD 音效播放封装 (可替换为其他音频方案)
│ ├── DamageSfxType.cs → 音效类型枚举模式 (可学习其分类设计)
│ └── Debug/NDebugAudioManager.cs → 调试用音频管理
│
├── 本地化层 (src/Core/Localization/) ──────────────
│ ├── DynamicVars/ → 运行时文本变量替换 (如 "{damage}点伤害")
│ ├── Fonts/ → 多语言字体回退链 (日/韩/俄/泰/中)
│ ├── Formatters/ → SmartFormat 格式化器封装
│ └── localization/目录 → 14语言 CSV/JSON 翻译文件结构
│
├── 存档系统 (src/Core/Saves/) ──────────────────────
│ ├── Managers/ → 存档管理器 (多槽位/自动保存)
│ ├── Migrations/ → (推荐学习) 存档版本迁移机制 (版本更新不丢档)
│ ├── Validation/ → 存档完整性校验
│ └── Runs/ → 单次游戏运行数据序列化
│
├── 设置系统 (src/Core/Settings/) ──────────────────
│ └── 音量/画面/按键绑定 等持久化设置管理
│
├── 平台抽象 (src/Core/Platform/) ──────────────────
│ ├── Null/ → 空平台默认实现 (无Steam等环境)
│ └── Steam/ → Steam SDK 集成 (成就/排行榜/Workshop)
│
├── 输入系统 (src/Core/ControllerInput/) ───────────
│ └── ControllerConfigs/ → 手柄按键映射配置 (Xbox/PS/Switch)
│
├── 开发者工具 (src/Core/DevConsole/) ──────────────
│ ├── ConsoleCommands/ → 控制台命令注册/解析/执行
│ └── 配合 scenes/debug/dev_console.tscn 使用
│
├── 日志系统 (src/Core/Logging/) ───────────────────
│ └── 结构化日志封装 (便于调试和崩溃报告)
│
├── 扩展工具 (src/Core/Extensions/) ────────────────
│ └── C# 扩展方法集 (字符串/集合/Godot类型等通用扩展)
│
├── 节点扩展 (src/Core/Nodes/GodotExtensions/) ─────
│ └── Godot 框架的补充工具方法
│
├── 对象池 (src/Core/Nodes/Pooling/) ───────────────
│ └── (推荐学习) 高性能节点对象池 (子弹/特效/卡牌等重复创建对象)
│
├── 随机数系统 (src/Core/Random/) ──────────────────
│ └── 可种子的随机数生成器 (确保回放一致性)
│
└── 命令模式 (src/Core/Commands/) ──────────────────
└── 操作抽象 + 撤销/重做基础 (可用于任何需要Undo的系统)

这里我借助民间文档和 AI 解析整体解包逻辑, 分析出可以引入到个人项目的流程

实际上在细读这部分代码之后, 发现内部项目其实问题还是挺多的; 游戏项目大范围采用硬编码形式写死场景路径, 很多时候能够看到内部写实的路径

1
2
"res://scenes/screens/*."
"res://images/map/placeholder/" + base.Id.Entry.ToLowerInvariant() + "_icon"

这部分代码后续维护和修改是很麻烦的, 不过直接编码写入的好处就是足够简单高效, 所以这部分仁者见仁智者见智

资源加载系统

最值得移植基础模块, 用于负责游戏内部的资源管理, 首先定义资源异常对象

1
2
3
4
5
6
7
8
9
// src/Core/Assets/AssetLoadException.cs
// 命名空间自定义空间即可

// 用于统一的资源加载异常抛出
public class AssetLoadException : Exception
{
public AssetLoadException(string message) : base(message) { }
public AssetLoadException(string message, Exception inner) : base(message, inner) { }
}

对于 资源缓存(AssetCache) 也是直接采用自定义类, 参考 src/Core/Assets/AssetCache.cs, 具体关注以下信息:

  • Dictionary<string, Resource> _cache: 用于统一管理存储已加载资源

  • Get<T>(path): 用于确认缓存命中返回/未命中加载并缓存

  • Preload(paths): 批量预加载缓存资源

  • Clear(): 清空缓存, 切换场景时释放资源

具体关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
using System.Collections.Concurrent;
using System.Collections.Generic;
using Godot;

// 命名空间这里省略


/// <summary>
/// 资源管理对象
/// </summary>
public class AssetCache
{

/// <summary>
/// 资源缓存
/// </summary>
private readonly ConcurrentDictionary<string, Resource> _cache = new ConcurrentDictionary<string, Resource>();

/// <summary>
/// 确认已经被缓存的资源
/// </summary>
private readonly HashSet<string> _missedCacheAssets = new HashSet<string>();

/// <summary>
/// 确认内部异常的资源
/// </summary>
private readonly HashSet<string> _failedAssets = new HashSet<string>();

/// <summary>
/// 从缓存中获取资源, 如果不存在则加载, 外部通过 GetScene 泛型方法调用转化
/// </summary>
/// <param name="path">资源路径</param>
/// <returns>对应资源</returns>
private Resource GetAsset(string path)
{
if (_cache.TryGetValue(path, out Resource value))
{
return value;
}

return LoadAsset(path);
}

/// <summary>
/// 从缓存中获取资源, 如果不存在则加载
/// </summary>
/// <param name="path">资源路径</param>
/// <typeparam name="TS">泛型 Resource 资源</typeparam>
/// <returns>Resource 派生对象</returns>
public TS GetAsset<TS>(string path) where TS : Resource
{
return (TS)GetAsset(path);
}

/// <summary>
/// 加载资源信息
/// </summary>
/// <param name="path">资源路径</param>
/// <returns>对应资源</returns>
/// <exception cref="AssetLoadException">找不到资源抛出异常 AssetLoadException </exception>
private Resource LoadAsset(string path)
{
// 确认是否为异常记录的资源
if (_failedAssets.Contains(path))
{
throw new AssetLoadException("Asset previously failed to load: " + path +
". The game installation may be corrupted.");
}

// 如果不存在则添加到确认列表中并写入日志
_missedCacheAssets.Add(path);
//Log.Warn("Asset not cached: " + path);
_cache[path] = ResourceLoader.Load<Resource>(path, null, ResourceLoader.CacheMode.Reuse);
return _cache[path];
}


/// <summary>
/// 场景切换时释放 "偶然加载" 的资源, 也就是针对内部场景临时构建的资源释放掉
/// </summary>
/// <param name="assetsToUnloadSet">需要卸载的列表集合</param>
public void UnloadAssets(IEnumerable<string> assetsToUnloadSet)
{
foreach (string item in assetsToUnloadSet)
{
if (!_missedCacheAssets.Contains(item))
{
RemoveAndGetResource(item)?.Dispose();
}
}
}


/// <summary>
/// 从缓存中移除资源
/// </summary>
/// <param name="key">内部记录的资源标识</param>
/// <returns>具体的删除的资源或者 NULL </returns>
private Resource? RemoveAndGetResource(string key)
{
if (_cache.TryRemove(key, out Resource value))
{
return value;
}

return null;
}

/// <summary>
/// 确定资源存在
/// </summary>
/// <param name="s">资源标识</param>
/// <returns>布尔值</returns>
public bool ContainsKey(string s)
{
return _cache.ContainsKey(s);
}

/// <summary>
/// 设置资源保存
/// </summary>
/// <param name="path">资源路径</param>
/// <param name="resource">资源对象</param>
public void SetAsset(string path, Resource resource)
{
_cache[path] = resource;
}


/// <summary>
/// 标识资源已经异常
/// </summary>
/// <param name="path">异常资源路径</param>
public void MarkAssetFailed(string path)
{
_failedAssets.Add(path);
}

/// <summary>
/// 获取场景资源
/// </summary>
/// <param name="path">资源路径</param>
/// <returns>资源对象</returns>
public PackedScene GetScene(string path)
{
return (PackedScene)GetAsset(path);
}

/// <summary>
/// 获取纹理资源
/// </summary>
/// <param name="path">资源路径</param>
/// <returns>资源对象</returns>
public Texture2D GetTexture2D(string path)
{
return (Texture2D)GetAsset(path);
}

/// <summary>
/// 获取材质资源
/// </summary>
/// <param name="path">资源路径</param>
/// <returns>资源对象</returns>
public Material GetMaterial(string path)
{
return (Material)GetAsset(path);
}

/// <summary>
/// 获取压缩纹理资源
/// </summary>
/// <param name="path">资源路径</param>
/// <returns>资源对象</returns>
public CompressedTexture2D GetCompressedTexture2D(string path)
{
return (CompressedTexture2D)GetAsset(path);
}


// 其他略
}

内部的日志系统推荐不照抄, 而是自己去 C# 社区对应的日志系统, 比如 Serilog 这类社区日志库

场景切换

游戏的场景切换也是可以借鉴学习的, 整体架构启动架构如下

1
2
3
4
5
6
7
8
9
10
11
12
┌────────────────────────────────────────────────────
│ scenes/game.tscn (入口场景)
│ └── NGame (Control) ← 总控制器
│ ├── NSceneContainer ← 场景容器
│ │ └── [当前显示的场景]
│ │ ├── NLogoAnimation (Logo动画)
│ │ ├── NMainMenu (主菜单)
│ │ ├── NRun (游戏内)
│ │ └── ...
│ ├── NTransition ← 过渡特效
│ └── [其他常驻组件]
└───────────────────────────────────────────────────

实际上的游戏启动流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
主菜单 → 开始游戏:

await Transition.FadeOut(); // 1. 黑屏遮罩
await PreloadManager.LoadRunAssets(character); // 2. 预加载游戏资源
RootSceneContainer.SetCurrentScene(NRun.Create()); // 3. 替换场景
await RunManager.Instance.EnterAct(0); // 4. 初始化游戏逻辑
// (FadeIn 由 RunManager 内部触发)

游戏结束 → 返回主菜单:

await Transition.FadeOut(); // 1. 黑屏遮罩
await PreloadManager.LoadCommonAndMainMenuAssets(); // 2. 重新加载主菜单资源
RunManager.Instance.CleanUp(); // 3. 清理游戏状态
await LoadMainMenu(); // 4. 替换为主菜单场景

首先是 场景容器(NSceneContainer) 的功能说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
using Godot;

/// <summary>
/// 场景容器对象
/// </summary>
public partial class NSceneContainer : Control
{
/// <summary>
/// 当前场景, 任何时候只有一个活跃场景, 也就是该对象
/// </summary>
private Control? _currentScene;

/// <summary>
/// 获取当前场景
/// </summary>
public Control? CurrentScene
{
get
{
if (_currentScene == null)
{
return null;
}

// 检查当前场景是否有效, GodotObject.IsInstanceValid 是 Godot 判断实例是否有效的静态方法
if (!GodotObject.IsInstanceValid(_currentScene))
{
return null;
}

// 检查当前场景是否被删除, IsQueuedForDeletion 是 Godot 判断实例是否被删除的实例方法
if (_currentScene.IsQueuedForDeletion())
{
return null;
}

return _currentScene;
}
private set => _currentScene = value;
}

/// <summary>
/// 切换当前场景
/// </summary>
/// <param name="node">Godot场景对象</param>
public void SetCurrentScene(Control node)
{
// 移除释放当前所有子对象
foreach (Node child in GetChildren())
{
// RemoveChildSafely 是自己编写扩展方法, 用于安全资源释放
// 实际上就是调用 parent.CallDeferred(Node.MethodName.RemoveChild, child); Godot 内部方法
this.RemoveChildSafely(child);
child.QueueFreeSafely(); // 自定义方法, 内部涉及资源池的释放
}

// 设置当前场景
CurrentScene = node;
if (node.GetParent() == null)
{
// 内部自定义安全添加场景, 最终调用 parent.CallDeferred(Node.MethodName.AddChild, child); Godot 内部方法
this.AddChildSafely(node);
}
else
{
// Reparent 是 Godot 的实例方法, 用于将节点从当前父节点移动到目标父节点
node.Reparent(this);
}
}
}

主要的涉及要点如下

  • 任何时候只允许有一个活跃场景

  • 旧场景直接 QueueFree 并且不保留历史残留

当然直接场景切换是很生硬的, 所以也就衍生出 过渡特效(NTransition) 的节点类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 场景过渡类
public partial class NTransition : ColorRect
{
// 通用的场景淡出 - 也就是类似伽马值压高直到覆盖界面所有画面
public async Task FadeOut(float time = 0.8f, string transitionPath = "res://materials/transitions/fade_transition_mat.tres", CancellationToken? cancelToken = null)
{
// 代码略
}

// 通用的场景淡入 - 也就是类似黑屏不断伽马值压低直到透明消失
public async Task FadeIn(float time = 0.8f, string transitionPath = "res://materials/transitions/fade_transition_mat.tres", CancellationToken? cancelToken = null)
{
// 代码略
}

// 而针对特定场景的切换就需要单独额外出来方法

// 特殊的房间淡出效果
public async Task RoomFadeOut()
{
// 代码略
}

// 特殊的房间淡入效果
public async Task RoomFadeIn()
{
// 代码略
}

// 如果你后续扩展不同场景都需要自定义淡出淡入
}
方法 效果 overlay 的 alpha 画面状态
FadeOut() 画面淡出(黑屏遮住) 0 → 1 旧场景被遮挡, 用户看到纯黑
FadeIn() 画面淡入(黑屏揭开) 1 → 0 新场景显露, 黑屏完全透明

这里先不做复杂实现, 可以简单自己做初级黑屏淡入淡出效果, 这边提供最初级的参考功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
using Godot;
using System.Threading.Tasks;

namespace Layout.Core.Nodes;

/// <summary>
/// 过渡效果功能类
/// </summary>
public partial class NTransition : ColorRect
{
/// <summary>
/// 是否处于正在过渡状态
/// </summary>
public bool InTransition { get; private set; }


/// <summary>
/// 初始化回调
/// </summary>
public override void _Ready()
{
// todo: 初始化
}

/// <summary>
/// 淡出效果: 画面逐渐被黑色遮罩覆盖
/// </summary>
public async Task FadeOut(float duration = 0.8f)
{
Color = new Color(Color, 0.0f); // 将透明值归零
InTransition = true; // 切换正在过渡中
Visible = true;
MouseFilter = MouseFilterEnum.Stop; // 过渡过程当中拦截所有鼠标点击事件

// 定时器设置
var tween = CreateTween().SetParallel(); // 设置并行定时器
tween.SetEase(Tween.EaseType.In).SetTrans(Tween.TransitionType.Quad); // 设置缓动类型为 Quad, 先快后慢
tween.TweenProperty(this, "color:a", 1.0f, duration); // alpha 0 → 1
await ToSignal(tween, Tween.SignalName.Finished); // 直接等待执行到异步任务完成
}

/// <summary>
/// 淡入效果: 黑色遮罩逐渐变为透明, 露出下方场景
/// </summary>
public async Task FadeIn(float duration = 0.8f)
{
Color = new Color(Color, 1f); // 将透明值归一
var tween = CreateTween().SetParallel();
tween.SetEase(Tween.EaseType.Out).SetTrans(Tween.TransitionType.Quad); // 设置缓动类型为 Quad, 先慢后快
tween.TweenProperty(this, "color:a", 0.0f, duration); // alpha 1 → 0
await ToSignal(tween, Tween.SignalName.Finished);

Visible = false;
MouseFilter = MouseFilterEnum.Ignore; // ★ 恢复鼠标穿透
InTransition = false;
}


/// <summary>
/// 直接过场调用的组合用法: FadeOut → 调用者切换场景 → FadeIn
/// </summary>
public async Task Transition(Task sceneTask, float fadeOutDuration = 0.8f, float fadeInDuration = 0.8f)
{
await FadeOut(fadeOutDuration); // 淡出当前场景
await sceneTask; // 等待场景切换完成
await FadeIn(fadeInDuration); // 淡入新场景
}
}

这样就能做个简单过渡效果类, 之后在主场景设定启动功能即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
using System;
using System.Threading.Tasks;
using Godot;
using Environment = System.Environment;

namespace Layout.Core.Nodes;

/// <summary>
/// 游戏启动节点
/// </summary>
public partial class NGame : Node
{
/// <summary>
/// 单例对象
/// </summary>
public static NGame? Instance { get; private set; }


/// <summary>
/// 场景过渡层
/// </summary>
public NTransition Transition { get; private set; }


/// <summary>
/// 按钮对象
/// </summary>
[Export]
public Button SceneButton { get; set; }


#region 是否运行主线程

/// <summary>
/// 私有的主线程ID标识
/// </summary>
private static int? _mainThreadId;

/// <summary>
/// 获取主线程ID
/// </summary>
public static bool MainThreadId
{
get
{
if (_mainThreadId.HasValue) return _mainThreadId == Environment.CurrentManagedThreadId;
_mainThreadId = Environment.CurrentManagedThreadId;
return true;
}
}

#endregion


/// <summary>
/// 节点唤醒
/// </summary>
public override void _EnterTree()
{
if (Instance != null)
{
GD.PrintErr("You are already running this game!");
QueueFree();
return;
}

Instance = this;

// 获取游戏场景过渡层, 这里采用 % 全局唯一节点语法
// % 前缀让 Godot 忽略节点路径中的层叠结构, 直接按名称全局查找
// 这里的 GameTransitionRect 是已经已经创建好的 ColorRect 唯一节点对象, 需要右键节点选择 '作为唯一名称访问'
// 该界面默认拉伸全屏并且设定 Color(0, 0, 0, 1) 透明黑色
Transition = GetNode<NTransition>("%GameTransitionRect");
Transition.Color = Colors.Black;
SceneButton.Pressed += OnSceneButtonDown;


// 启动模拟延迟2s后加载资源等待后续加载主场景
try
{
_ = LoadMainScene();
}
catch (Exception ex)
{
if (ex is not TaskCanceledException) // 取消不算错
{
GD.PrintErr(ex.ToString());
}

throw; // 仍然抛出,让 Godot 的全局异常处理器也能感知
}
}

/// <summary>
/// 加载游戏主场景
/// </summary>
private async Task LoadMainScene()
{
// 测试场景淡入效果, 实际需要初始化大量之后才会被调用, 这里只是做演示所以不做处理
await Transition.FadeIn(0.8f);
}

/// <summary>
/// 异步点击调用场景淡出
/// </summary>
private async void OnSceneButtonDown()
{
await Transition.FadeOut(0.8f); // 黑屏遮罩
// todo: 场景加载内容
await Transition.FadeIn(0.8f); // 揭开黑屏
}
}

这里挂载的节点结构如下, 需要将该节点设定为全局唯一节点采用被直接检索到

transition

可以说这份游戏代码项目有很多值得借鉴的地方, 碍于篇幅过长只能稍微介绍下部分比较实用的点, 后续会单独针对某些模块实现来说明