前言: 该文档仅针对DungeonShooting_Godot
目录下的Godot工程
第一次编写日期: 2023-04-01
目录:
Godot版本: Godot4x
.net版本: .net6.0
使用Godot打开project.godot
, 如果是第一次打开项目会弹出一个找不到资源的提示, 这是因为项目没有编译过, 点击Godot右上角build
, 然后打开项目设置
, 在插件
这一个页签下启用DungeonShooting_plugin
这个插件, 然后项目就可以正常运行了
所有资源严格划分类别, 并放入指定的文件夹
项目目录结构如下:
为了方便代码获取资源以及排除代码中引用丢失资源的情况, 项目中使用ResourcePath
类来放置所有资源路径, 该类常量值即代表资源路径, 使用ResourceManager.Load()
来加载资源
举个例子, 某资源在编辑器中的路径为:
res://resource/theme/mainTheme.tres
那么在ResourcePath
中的代码就为:
public const string resource_theme_mainTheme_tres = "res://resource/theme/mainTheme.tres";
加载该资源的代码为:
var resource = ResourceManager.Load<Theme>(ResourcePath.resource_theme_mainTheme_tres);
如果项目中有资源变动, 则可以使用Tools
页签下的重新生成ResourcePath.cs文件
游戏框架分为三部分:
游戏核心系统: 以游戏玩法为中心的逻辑代码, 包括玩家, 敌人, 武器, 被动, 道具, 地牢生成, 房间规则, 存档逻辑等
UI模块系统: 用户操作界面的逻辑代码
代码生成系统: 自动生成便于开发的资源的逻辑代码, 包括生成UI模板, 生成地牢模板, 生成代码等
在Main/ViewCanvas/SubViewportContainer/SubViewport
的子节点将开启4倍缩放, 并且启用完美像素
该节点放置除UI以外的任何节点
定义: 游戏内所有可活动物体的基类叫做ActivityObject
源代码: ActivityObject.cs
ActivityObject
的意由来: 为了方便统一管理物体, 并且减少子类代码沉积, 因此将所有活动物体都需要用到的逻辑抽到一个统一的类中, 并命名为ActivityObject
, 所有的活动物体都需要继承该类
ActivityObject
提供的基础功能:
Component
组件管理通过下面这张图可以了解游戏中的物体与ActivityObject
的关系 (注意: 该图为早期开发版本的继承关系图, 后面开发可能会有修改)
定义: Activity模板场景
是指可以可以被实例化出ActivityObject
对象的场景, 但是场景根节点必须是ActivityObjectTemplate
节点
上面定义看起来有矛盾: ActivityObjectTemplate
没有继承ActivityObject
, 为啥以它为根节点的场景却能实例化出ActivityObject
?
这就得提到一个概念: 场景与脚本分离, 顾名思义, 场景中的节点与ActivityObject
的脚本是完全分离的, 场景中的节点并没有挂载ActivityObject
脚本, 在编辑器中它们是两互不干涉的.
游戏运行中, 如果需要实例化ActivityObject
, 那么就先需要在ActivityObject
脚本代码中指定该物体的模板场景, 实例化过程中游戏会先实例化出模板场景, 再用ActivityObject
的实例顶替掉模板场景的根节点, 因此就能打到最终的效果. 为什么要这么做? 原因很简单, 因为我们的游戏是一个Roguelite游戏, 因此游戏中肯定会有大量的武器道具和敌人来填充内容, 但是总会有类似功能或者类似场景结构的物体, 这样就没有必要每一个物体都新建一个单独的场景, 而是让功能让这些类似功能或者结构的物体使用同一个场景, 但为了因对有不同行为逻辑的物体, 我们就设计了一套场景与脚本分离的设计模式来因对上述情况
总结: Activity模板场景
是不挂载逻辑脚本的, 但是ActivityObject
必须包含使用的模板场景, 并由统一的Api来实例化ActivityObject
对象, 至于ActivityObject
如何绑定模板场景, 请看: 3.2.3.如何创建一个ActivityObject
通过下面这张图可以更好的立即Activity模板场景
和ActivityObject
的关系
(缺张图...)
这里的创建分为两步:
创建一个空场景, 并且添加ActivityObjectTemplate
节点
创建完成后编辑器会自动创建必要的子节点
此时就可以随意添加子节点和重命名更节点了, 最后记得保存到./prefab
文件夹下
注意: ShadowSprite
,AnimatedSprite
,Collision
这三个节点不能改名, 但是可以修改属性和添加子节点
创建脚本放到在./src/game
下, 脚本必须继直接或间接承ActivityObject
, 并且需要在类上加[RegisterActivity(id, path)]
标记用于注册对象, 物体id
必须唯一
源代码: RegisterActivity.cs
参考代码如下:
using Godot; [RegisterActivity("物体唯一Id", "模板场景路径")] public partial class YourActivity : ActivityObject { }
为了方便区分物体类型, 可以使用ActivityIdPrefix
类中的常量来添加id
前缀, 目前支持的类型如下:
例如我们创建一个敌人, 那么[RegisterActivity()]
就可以这么写:
[RegisterActivity(ActivityIdPrefix.Enemy + "0001", ResourcePath.prefab_role_Enemy_tscn)]
可通过ActivityObject.Create(id)
创建物体, 这个id
可以结合ActivityIdPrefix
, 那么创建敌人最终可以这样写
var enemy = ActivityObject.Create<Enemy>(ActivityIdPrefix.Enemy + "0001");
某些情况下需要更改RegisterActivity
的参数, 或者需要对实例化出来的ActivityInstance
进行统一的操作, 那么就需要我们自己写一个子类来继承RegisterActivity
.
操作ActivityInstance
需要重写:
/// <summary> /// 该函数在物体实例化后调用, 可用于一些自定义操作, 参数为实例对象 /// </summary> public virtual void CustomHandler(ActivityObject instance) { }
例子: 注册武器, RegisterWeapon.cs
由于创建武器必须指定武器属性数据, 那么原来的[RegisterActivity()]
就不适用了, RegisterWeapon
重写了构造函数, 改变了初始化参数, 并且重写CustomHandler()
, 对ActivityInstance
进行初始化属性操作
这个功能类似于Unity
的MonoBehaviour
, 组件必须继承Component
类, 组件的作用是拆分功能代码, 开发者可以将相同功能的代码放入同一个组件中, 与Godot
的Node
不同的是, 挂载到ActivityObject
上的组件并不会生成一个Node
节点, 它相比于Node
更加轻量
自定义组件代码:
public class MyComponent : Component { }
调用ActivityObject.AddComponent()
添加组件:
var component = activityInstance.AddComponent<MyComponent>();
注意: 一个ActivityObject
上不允许挂载多个相同的组件
ActivityObject
的移动由自身的MoveController
组件控制, 非特殊情况下不要直接修改ActivityObject
的位置, 而是使用MoveController.AddConstantForce()
函数来添加外力
//添加一个向右的外力, 速度为100 var force = activityInstance.MoveController.AddConstantForce("ForceName"); //外力必须起名称, 而且在运动控制器中必须唯一 force.Velocity = new Vector2(0, 100); //以下为精简写法 var force = activityInstance.MoveController.AddConstantForce(new Vector2(0, 100), 0); //创建匿名外力, 但是与上面不同的是当速率变为 0 时自动销毁
物体的运动方向就是所有外力总和的方向, 通过MoveController.Velocity
可以获取当前运动速度
当游戏中需要制作飞行物体或者模拟投抛运动时, 就需要控制物体纵轴所处高度, ActivityObject
中提供了一系列控制纵轴方向运动的属性和函数, 以下列举几个关键属性和函数:
/// <summary> /// 当前物体的海拔高度, 如果大于0, 则会做自由落体运动, 也就是执行投抛代码 /// </summary> public float Altitude { get; set; } = 0; /// <summary> /// 物体纵轴移动速度, 如果设置大于0, 就可以营造向上投抛物体的效果, 该值会随着重力加速度衰减 /// </summary> public float VerticalSpeed { get; set; } = 0; /// <summary> /// 物体下坠回弹的强度 /// </summary> public float BounceStrength { get; set; } = 0.5f; /// <summary> /// 物体下坠回弹后的运动速度衰减量 /// </summary> public float BounceSpeed { get; set; } = 0.75f; /// <summary> /// 是否启用垂直方向上的运动模拟, 默认开启, 如果禁用, 那么下落和投抛效果, 同样 Throw() 函数也将失效 /// </summary> public bool EnableVerticalMotion { get; set; } = true;
垂直运动也提供了一些可供重写的虚函数:
/// <summary> /// 开始投抛该物体时调用 /// </summary> protected virtual void OnThrowStart() { } /// <summary> /// 投抛该物体达到最高点时调用 /// </summary> protected virtual void OnThrowMaxHeight(float height) { } /// <summary> /// 投抛状态下第一次接触地面时调用, 之后的回弹落地将不会调用该函数 /// </summary> protected virtual void OnFirstFallToGround() { } /// <summary> /// 投抛状态下每次接触地面时调用 /// </summary> protected virtual void OnFallToGround() { } /// <summary> /// 投抛结束时调用 /// </summary> protected virtual void OnThrowOver() { }
如果需要模拟飞行效果则需要设置Altitude
值大于0, 并且将EnableVerticalMotion
设置为false
如果需要自由落体, 则直接设置Altitude
值大于0
如果需要上抛运动, 则直接设置VerticalSpeed
值大于0
如果值BounceStrength
和BounceSpeed
设置成1, 则投抛的物体在地上会一直朝一个方向弹跳
如果需要投抛物体不需要每个关键值都设置一遍信息, 只需要调用ActivityObject.Throw()
函数即可:
/// <summary> /// 将该节点投抛出去 /// </summary> /// <param name="altitude">初始高度</param> /// <param name="rotate">旋转速度</param> /// <param name="velocity">移动速率</param> /// <param name="verticalSpeed">纵轴速度</param> public void Throw(float altitude, float verticalSpeed, Vector2 velocity, float rotate);
调用示例, 模拟弹壳投抛落在地上弹跳的过程
var startPos = GlobalPosition; var startHeight = 6; var direction = GlobalRotationDegrees + Utils.RandomRangeInt(-30, 30) + 180; var verticalSpeed = Utils.RandomRangeInt(60, 120); var velocity = new Vector2(Utils.RandomRangeInt(20, 60), 0).Rotated(direction * Mathf.Pi / 180); var rotate = Utils.RandomRangeInt(-720, 720); var shell = ActivityObject.Create<ShellCase>(ActivityIdPrefix.Shell + "0001"); shell.Throw(startPos, startHeight, verticalSpeed, velocity, rotate);
该功能与Unity
的协程功能类似, 在协程函数中通过yield
关键字暂停执行后面的代码, 并将控制权返还给ActivityObject
, 协程常被用在动画处理和资源异步加载ActivityObject
中协程相关函数:
/// <summary> /// 开启一个协程, 返回协程 id, 协程是在普通帧执行的, 支持: 协程嵌套, WaitForSeconds, WaitForFixedProcess /// </summary> public long StartCoroutine(IEnumerator able); /// <summary> /// 根据协程 id 停止协程 /// </summary> public void StopCoroutine(long coroutineId); /// <summary> /// 停止所有协程 /// </summary> public void StopAllCoroutine();
协程yield return
返回特殊值类型如下:
协程yield return
如果返回除以上数据类型以外的数据, 将忽略返回值
调用实例, 以下代码在ActivityInstance
初始化时执行协程StartRotation
, 协程在60帧内让物体每帧角度加1
public override void OnInit() { StartCoroutine(StartRotation()); } private IEnumerator StartRotation() { for (int i = 0; i < 60; i++) { RotationDegrees += 1; //结束这一帧, 返回0会被忽略返回值 yield return 0; } }
游戏中的地牢由若干层组成, 每一层地牢又由数个小房间随机拼接而成, 由起始房间开始, 成树状连接; 每一层地牢有一个起始房间, 和至少一个通向另一层的结束房间, 房间与房间之间由过道连接, 过道不会交叉和重叠
房间有以下类别 (目前代码还未完成区分类型的功能):
图块层级概述(后续补上, 先默认使用resource/map/tileset/TileSet1.tres
)
项目提供了一套创建模板房间的工具, 点击tools
页签, 找到创建地牢房间
这一项, 输入模板房间名称(注意房间名称不能重复), 即可创建房间, 创建房间完成后会创建房间配置数据, 路径为resource/map/tiledata/xxx.json
, 并将配置数据注册到resource/map/RoomConfig.json
中
创建好的房间会自动在编辑器中打开, 为场景的根节点选好TileSet
后就可以画房间了
编辑器会自动计算出房间位置轮廓(绿色线)和导航区域(红色和黄色线), 并绘制出来, 按下ctrs
+s
, 编辑器就会将位置轮廓和导航信息存入resource/map/tiledata/xxx.json
下
注意, 为了避免Ai运动时卡墙角, 所以计算导航轮廓时特意与墙预留了半个格子的距离, 也就是说如果存在单格的道路, 导航计算就会出错, 所以在画道路时必须为两格以上的宽度, 像下面这两种情况就是不被允许的, 编辑器会绘制出错误的格子
如果计算导航网格出错, 那么编辑器将不会保存房间配置信息
如果某些模板房间需要在指定区域内生成门, 那么就需要设置房间门生成区域
在模板场景中选中根节点, 再勾选Enable Edit
此时将鼠标放置在房间轮廓的绿线上就会显示生成区域
点击鼠标左键即可创建门区域, 如果悬停时显示红色方块, 则表示不能在此处创建门区域
创建门生成区域的约束: 区域不能重叠, 且两个区域的间距至少为4格, 每个区域至少4格宽度
新建的区域默认为4格宽度, 如果需要调整宽度, 可以拖拽区域两侧的点来调整范围
如果需要删除区域, 则悬停到区域两侧任意一个点上, 按下鼠标中建即可删除
门区域需要对齐地面地砖
注意:
*
(编辑器不会认为该资源有修改), 修改后需要及时按下ctrl
+s
保存, 以免造成数据丢失ActivityMark
用于模板房间中创建ActivityObject
对象, 并支配置指定的物体, 第几波生成该物体以及生成物体延时时间
源代码: ActivityMark.cs
在房间根节点中创建ActivityMark
对象
创建完成后可以看到地图上多了一个X
, 这个叉就是生成物体的位置, 可自由调整位置
然后就可以在编辑器中设置ActivityMark
数据了, ActivityMark
有以下可以导出的属性:
/// <summary> /// 物体类型 /// </summary> [Export] public ActivityIdPrefix.ActivityPrefixType Type = ActivityIdPrefix.ActivityPrefixType.NonePrefix; /// <summary> /// 物体id /// </summary> [Export] public string ItemId; /// <summary> /// 所在层级 /// </summary> [Export] public RoomLayerEnum Layer = RoomLayerEnum.NormalLayer; /// <summary> /// 该标记在第几波调用 BeReady, /// 一个房间内所以敌人清完即可进入下一波 /// </summary> [Export] public int WaveNumber = 1; /// <summary> /// 延时执行时间,单位:秒 /// </summary> [Export] public float DelayTime = 0;
项目中提供了以下几个扩展ActivityMark
属性的节点:
游戏内的物体, 例如ActivityObject
等都是在Main/ViewCanvas/SubViewportContainer/SubViewport
节点下, 并且启用了完美像素, 但是UI恰恰相反,它们直接位于Main
节点下, 既没有4倍缩放也没有完美像素 游戏中的UI分为4个层级, 分别为
UI场景根节点必须继承UiBase
类, 并且生命周期由UiManager
控制
UI代码放置位置: src/game/ui/**/**.cs
UI场景资源放置位置: prefab/ui/**.tscn
源代码: UiBase.cs, UiManager.cs
打开指定UI:
var ui = UiManager.OpenUi("UI名称");
关闭Ui
UiManager.DisposeUi(ui);
为了减低开发者制作UI的复杂程度, 避免手写获取UI节点的代码, 我们设计了一套自动生成UI层级代码的功能, 该功能在编辑器中会监听开发者对于UI场景的修改, 并及时生成相应的UI代码, 并且开发者的UI逻辑类继承生成的UI类, 即可方便的获取UI节点, 可以节省大量时间, 因为代码是实时生成的, 因此一旦有节点改名或者移动位置, 重新生成UI代码后, 引用该节点的代码就会出现编译错误, 方便开发者修改
在Tools
页签下找到创建游戏UI
, 输入UI名称即可点击创建UI
创建完毕后编辑器会离开打开该UI场景
观察文件系统可以注意到, 编辑器为我创建并保存了场景和代码, 并且还生成了一个MyUiPanel.cs
的文件, 该文件就是我们写UI逻辑代码的地方, 并且命名方式为UI名称
+Panel
, 这个Panel类继承了自动生成出来的UI类
动态生成的UI代码的节点对象由IUiNode
包裹, 为了子节点与内助属性区分方便, 生成出来的代码会为每一层的名称加上前缀L_
, 同理如果需要获取子节点则直接寻找以L_
开始的属性
例如节点在编辑器的路径为Group/Button
, 那么在代码里就是L_Group.L_Button
源代码: IUiNode.cs
通过以下这个gif就可以直观感受到该功能的便捷之处
创建完成UI后, 编辑器也会在UiManager
中生成打开该UI和获取UI实例的Api
那么可以直接调用UiManager
中的函数打开该UI
UiManager.Open_MyUi();
UiBase
包含4个生命周期函数:
/// <summary> /// 创建当前ui时调用 /// </summary> public virtual void OnCreateUi() { } /// <summary> /// 当前ui显示时调用 /// </summary> public abstract void OnShowUi(); /// <summary> /// 当前ui隐藏时调用 /// </summary> public abstract void OnHideUi(); /// <summary> /// 销毁当前ui时调用 /// </summary> public virtual void OnDisposeUi() { }
获取Node
实例: 使用例如L_Group.L_Button
的代码获取的节点并不是Godot
节点对象, 而是包裹对象, 需要从Instance
属性中获取原生Node
对象
克隆节点: 使用IUiNode.Clone()
可以完整的克隆当前节点以及子节点 嵌套UI: 使用IUiNode.OpenNestedUi()
即可以当前节点为根节点打开子级UI