实现目标
2D URP恶魔城动作游戏——基于我参加吉比特大赛的游戏重构而成。
技术栈
渲染——官方URP
地图构建——瓦片地图
角色动作切换——FSM状态机
通用管线没有效果主要是因为材质没有升级导致的。
总体替换材质:Window → Rendering → Render Pipeline Converter → 在弹出窗口的下拉框中选择Convert Built-in to 2D(URP) → 勾选Material and Material Reference Upgrade → 点击Initialize Converters,这时候中心窗口会弹出修改了哪些对象 → Convert Assets按钮亮起,点击完成材质升级
这个操作能够一次性升级项目中的默认材质,但无法应用到自定义材质身上,那些使用自定义材质的对象无法通过这个升级,因此需要手动修改这些自定义
单独替换材质:在Project选中材质 → (Shader) → Universal Render Pipeline → 2D → Sprite-Lit-Default
注意点:
-
Unity项目中必须上传的3个文件夹
-
Assets——关系到项目的资源
-
Packages——关系到导入的一些包
-
ProjectSettings——关系到你的项目设置
一次性事件的实现
1.在不同地图各自注册好所有有关的一次性事件委托,并在初始化时添加到eventDic中
2.创建一个新的脚本挂载到碰撞体充当事件类,负责触发事件,被触发的事件是一个公共枚举,供外界面板修改变量,含有一个bool值,记录是否触发过一次性事件。
3.触发后执行一次性事件,随后修改布尔值为false后不再触发事件
4.之后角色靠近也不再会触发此事件,一次性事件执行完毕
分层
- 背景层:这个层次用来放置最远处的景物,例如天空、云彩、山脉等。这些景物通常不会随着玩家的移动而改变位置,或者只会有很小的视差效果。
- 远景层:这个层次用来放置稍微靠近一点的景物,例如树木、建筑、岩石等。这些景物会随着玩家的移动而产生一定的视差效果,让地图看起来有深度感。
- 中景层:这个层次用来放置最常见的地形元素,例如草地、沙漠、水面等。这些元素会占据大部分的画面空间,也是玩家最容易接触到的部分。
- 近景层:这个层次用来放置一些靠近玩家的景物,例如花草、灌木、石头等。这些景物可以增加地图的细节和丰富度,也可以作为遮挡物或障碍物。(玩家所在层)
- 前景层:这个层次用来放置一些在玩家前方的景物,例如雾气、雨滴、飘落的叶子等。这些景物可以增加地图的氛围和动态感,也可以给玩家一种身临其境的感觉
使用的插件
1.虚拟相机
2.TimeLine
存档系统
相关类
DataMgr:数据管理类
其他管理单例,如MusicMgr,PlayerFSM
流程
数据管理类拥有一份其他单例类上拥有的变量,如音量变量等,可以理解为一份拷贝。
数据管理类提供各类数据的保存和读取方法,并记录运行中产生和修改的数据。
管理单例则提供供外界数据修改的方法,并且直接将变量和数据管理类的拷贝绑定(引用类型直接等于,值类型用get,set)
一些复杂功能的实现流程
主角的攻击实现
1.在主角的属性类里面加上攻击float变量:atk,负责充当主角的基础属性参与伤害计算。
2.在主角的攻击状态脚本里面添加一个变量:damage,充当主角的某次招式造成的实际伤害,这个伤害会根据主角施展的攻击类型,通过对第一步的atk作为基础值来进行变量的加减乘除,最后进行进行实时的改变。
最后由第三步的攻击碰撞体调用damage变量,即可对敌人造成伤害。
3.为主角加上攻击碰撞体对象,在攻击动画里面做好对碰撞体的攻击范围调试,然后为攻击碰撞体加上AttackCollider脚本,然后用OnTriggerEnter2D方法来判定攻击逻辑,即调用敌人的受伤函数。
private void OnTriggerEnter2D(Collider2D col)
{
if (col.gameObject.layer==LayerMask.NameToLayer("Enemy"))
{
col.GetComponent<Enemy>().Damage(Fsm.GetStateCompment<Attack>(StateType.Attack).Damage); //对攻击范围内的敌人传入伤害
}
}
碰到的难题
awake深坑
设有一个mono脚本对象T,当 对象=addcompment< T >();时候,T会在赋值未完成之前直接执行完毕awake。如果涉及到单例赋值的时候需要小心。
Composite Collider 2D深坑
如果你发现使用 Composite Collider 2D合并瓦片地图后,又将摩擦力Function设置为0后,角色的velocity出现了诡异的变化(比如停止的时候突然变成一个巨大的值),不妨试试将Composite Collider 2D的offsetDistance设置为0即可解决,否则默认值5e-05疑似会留下细微的缝隙导致奇怪的物理反应,根据国内外资料,目前尚不知Unity设置为此值的原因。
射线获取攀爬瓦片对象的坑
问题
使用射线获取瓦片对象的时候会有问题——由于我直接使用复合碰撞体,无法获得单个瓦片的坐标,所以我只能通过hit.point(即碰撞点),然后通过point的点获取到对应的瓦片坐标,然后将瓦片坐标转换为世界坐标,来获取攀爬的瓦片对象的世界坐标。
但是这又引出了一个问题,瓦片之间是紧密相连的,由碰撞点获取的瓦片有没有可能是旁边那个呢?
不幸的是,我碰到了这个问题,每次我的角色向左的时候固定获取的瓦片都会固定是目标瓦片旁边的空瓦片,导致角色的攀爬点判定失误,直接浮空。
所幸的是,在我的项目里面,这似乎是固定的,我猜测这依然和瓦片的内部存在某些细微的向右偏移有关。
本项目的解决方案
既然是固定的,那不妨直接判定角色方向向左的时候,直接把瓦片坐标左移一个即可。
我的攀爬脚本代码如下(省略了没用到的状态方法):
public class Climb : EnterState
{
private Vector2 pos;//玩家位置
private RaycastHit2D hit;//检测攀爬瓦片的射线
private Tilemap tilemap;//攀爬瓦片的瓦片组件
private Vector3Int cell;//攀爬瓦片的瓦片单元格坐标
public Vector3 world;//攀爬瓦片的世界坐标
public Climb(PlayerFSM playerFsm) : base(playerFsm)
{
}
public override void OnEnter()
{
pos = manager.transform.position; // 获取当前玩家位置
// 根据玩家朝向方向,从玩家胸口处发射一条射线,获取所攀爬的瓦片
if (manager.toward==toward.left)
{
hit = Physics2D.Raycast(pos+new Vector2(-0.55f, 0.55f),
Vector2.left,
0.4f,
LayerMask.GetMask("Climb"));
}
if (manager.toward==toward.right)
{
hit = Physics2D.Raycast(pos+new Vector2(0.55f, 0.55f),
Vector2.right,
0.4f,
LayerMask.GetMask("Climb"));
}
//根据攀爬点获取情况来处理状态
if (hit.collider!=null)
{
Tilemap tilemap = hit.collider.GetComponent<Tilemap>(); // 获取碰撞体组件上的Tilemap组件
cell = tilemap.WorldToCell(hit.point); // 获取瓦片地图上的单元格坐标
//因为不明bug,角色向左攀爬时会凭空多一格,所以得减去
if (manager.toward==toward.left)
{
cell = new Vector3Int(cell.x-1, cell.y);
}
world = tilemap.GetCellCenterWorld(cell); // 获取单元格中心的世界坐标
manager.transform.position = new Vector2(world.x + (manager.toward==toward.left ? 1f : -1f),
world.y - 1.8f);
//定住玩家
manager.StopMove();
manager.rigidbody.gravityScale = 0;
Play("LedgeGrab");
}
else
{
Debug.Log("没有获取到攀爬对象!");
manager.StopSideRay(0.5f);
TransitionState(StateType.Jump);
}
}
}
最终解决
那如果是不固定的呢?也好办,直接判定对象身上的层级是否复合(攀爬点通常得改一改层级来方便识别),不符合的话,我们已经有瓦片坐标,通过瓦片坐标获取相邻的那个瓦片层级是否复合,复合的即为我们的目标攀爬瓦片。
Animation系统的坑——关于关键帧锁定属性
导入
这是我在设计RollingFish的时候碰到的问题。
这方面在中文互联网讨论的不多,这里我给出外网讨论此问题的一个地址:How to dynamically set animation key frame value? - Unity Forum
问题
在Animation中,如果你采用关键帧修改某个属性,有两个问题你必须得注意:
1.一旦在Animation面板里为某个动画添加了修改了某个组件上属性的关键帧,这个属性有关联的值和所在组件的enable都会被animator接管,只要这个角色身上的animator存在这个Animation,你将无法在Animator体系外更改它(使用代码甚至是面板进行更改都不行)
也就是说,你无法进行这个操作——在第一帧设置了某个碰撞体激活后,打算用代码在某个条件下直接失活它,你会发现没有反应,因为动画系统会随时覆盖掉你的改动。
2.你可能会问,只有某个动画在播放的时候会遵循这个规则吗,不同动画之间呢?答案是:整个animator将会共享一个属性
也就是说,你在某一个动画里面进行了属性关键帧修改,你如果试图在其他没有进行有关的属性关键帧修改的动画的状态下去对这个属性进行代码和面板的修改,也是被锁死的。可以说除非你替换掉animator,否则这个animator存在一天,只要有一个动画的关键帧涉及到了某个属性的更改,有关属性就会被Animator直接接管掉!
3.当设置该属性的动画结束之后,切换到其他动画后,此属性会回到初始默认值。
解决思路
1(推荐).直接用动画事件,灵活且扩展性和掌控力强,唯一的缺点是可能需要专门写一个脚本,而且一大堆事件和动画事件散落在各处,维护起来有点繁琐。
上文链接的老外也推荐用这种方法。
2.通过animator提供的代码直接对animation进行动态修改。
UI和生命周期函数
导入
当设计UI初始化的时候,函数执行顺序为:Awake->onenable->Showpanel里面的委托函数->start。
问题
我的项目原版打算直接靠Unity自带的生命周期函数和showpanel里面的委托来解决UI的初始化问题,原本游刃有余,但是当我打算用缓存池来存储UI对象的时候,我很快发现了一大堆问题:
1.Start和OnEnable类似,它们都会在第一次激活的时候执行,而Start 只有创建的时候会执行一次 ,OnEnable才可以在缓冲池激活的情况下每一次都执行。那么,如何处理第一次创建面板的时候它们两的逻辑冲突呢?正常来说,肯定是负责不同功能
正常情况下,start负责挂载脚本,Oneable负责初始化面板和设置面板数据,是一个广泛的思路,但是,引出问题如下
2.Oneable执行的是比Start早的!你如果试图在start里面执行挂载对象,你会发现Onenable想调用的时候,直接报空错误。
这导致了一个悖论——Start只执行一次,本来固定的UI对象让它负责挂载是最合理的,但是它偏偏执行在Onenable后面,所以基本可以缺点UI里面最好不要用Start了,直接用Awake来进行挂载功能。
3.Showpanel里面的委托函数执行的比Oneable晚! ,而我们经常用委托函数来传递从这个UI外面输入的外界变量。这导致,如果我们试图在Onenable里面通过委托函数传入的变量进行初始化(比如对话触发器触发对话UI的时候会传递一个对话文件给UI),我们会发现报空错误。
总结:start和onenable都不能较好的满足我们的初始化需求,经常会导致初始化报空。
解决思路
我们直接舍弃掉Start和OnEable,自己为Basepanel设置几个需要子类实现的周期函数方法,然后在Showpanel的时候控制触发时机。
如下文代码中的方法:
Init:Awake替代,发生在showpanel前,只在被创建激活的时候执行一次,负责挂载对象脚本
InitializeLater:Onenable替代,但是发生在Showpanel后面,每次激活都会执行一次,负责初始化面板,不论是第一次创建还是取出对象池。
EG:
BasePanel:
// Use this for initialization
protected virtual void Awake () {
FindChildrenControl<Button>();
FindChildrenControl<Image>();
FindChildrenControl<Text>();
FindChildrenControl<Toggle>();
FindChildrenControl<Slider>();
FindChildrenControl<ScrollRect>();
FindChildrenControl<InputField>();
canvas = this.transform.GetComponent<CanvasGroup>();
if (canvas == null)
{
canvas = this.transform.AddComponent<CanvasGroup>(); //用来显隐面板的组件
}
Init();
}
/// <summary>
/// 初始化(发生在Showpanel前面,并且只有被创建出来的时候会行动一次,被缓冲池拿出后也不再执行,适用于第一次设定固定数据)
/// </summary>
public abstract void Init();
/// <summary>
/// 初始化(发生在Showpanel后面)
/// </summary>
public abstract void InitializeLater();
UIMgr:
public void ShowPanel<T>(string panelName, float changeTime=0,E_UI_Layer layer = E_UI_Layer.Mid, UnityAction<T> callBack = null) where T:BasePanel
{
if (panelDic.ContainsKey(panelName))
{
// 处理面板创建完成后的逻辑
if (callBack != null)
callBack(panelDic[panelName] as T);
(panelDic[panelName] as T).InitializeLater();
//避免面板重复加载 如果存在该面板 即直接显示 调用回调函数后 直接return 不再处理后面的异步加载逻辑
return;
}
PoolMgr.GetInstance().GetObj(Consts.UIPath + panelName, (obj) =>
{
//把他作为 Canvas的子对象
//并且 要设置它的相对位置
//找到父对象 你到底显示在哪一层
Transform father = bot;
switch(layer)
{
case E_UI_Layer.Mid:
father = mid;
break;
case E_UI_Layer.Top:
father = top;
break;
case E_UI_Layer.System:
father = system;
break;
}
//设置父对象 设置相对位置和大小
obj.transform.SetParent(father);
obj.transform.localPosition = Vector3.zero;
obj.transform.localScale = Vector3.one;
(obj.transform as RectTransform).offsetMax = Vector2.zero;
(obj.transform as RectTransform).offsetMin = Vector2.zero;
//得到预设体身上的面板脚本
T panel = obj.GetComponent<T>();
// 处理面板创建完成后的逻辑
if (callBack != null)
callBack(panel);
//处理完传入的逻辑后,才会执行onenable
panel.InitializeLater();
panel.ShowMe(changeTime);
//把面板存起来
panelDic.Add(panelName, panel);
});
}
局限性
InitializeLater方法仅限于被Showpanel方法创建的UI会进行执行。
而对于某些附属UI,如类似商店UI里面的物品格子的UI类型,由于它们具有重复性,我们通常不用Showpanel取进行生成,这种情况下,我们需要手动执行 InitializeLater方法
shadow caster 2D失效
导入
2D shadow caster脚本可以在Light2D光照下为Unity2D游戏方便的实现阴影,但是在本人使用的2021.3.13f1c1版本中有一个系统BUG。
那就是,2D shadow caster仅会对Light order最高的2DLight脚本生成阴影。即无论我在场景中添加了多少个 Light2D,ShadowCaster2D 都只能对其中一个光源生成阴影。
外网同类题交流:Bug - 2D shadow caster not working correctly…? - Unity Forum
解决
直接提高版本,据我测试,升级到2022.3.11f1c1版本即可解决问题(还好更新版本的时候没有出什么状况)。
序列化问题
问题
序列化对象如果是场景里面一开始就存在的对象,不需要进行初始化操作就可以直接使用。
而如果一开始场景里面不存在这个对象,动态创建出来的话,需要手动的对序列化对象进行初始化,否则这个序列化对象会报空,即使看到了面板里面已经有序列化变量的信息了,这个过程类似为这个信息和面板进行了连接。
解析
在Unity中,序列化对象的行为确实可能根据它们是在场景中原始存在还是在运行时动态创建的而有所不同。这种行为差异通常与Unity的序列化和反序列化机制有关。
-
场景中原始存在的对象:当Unity场景被加载时,所有在编辑器中已经存在的序列化对象都会被自动初始化。这意味着它们的序列化字段将会被Unity反序列化,并且设定为在编辑器中你设置的值。
-
运行时创建的对象:当你在运行时创建一个新的对象时,Unity不会自动对其进行反序列化。即使你已经在Inspector面板中为它设置了序列化变量,这些值在对象第一次实例化时并不会自动应用。这种情况下,你需要手动初始化这些变量。
这种行为的原因是Unity的序列化系统主要设计用于编辑器,并在场景加载时自动应用,而不是在运行时。当你在运行时创建一个新对象时,它的序列化字段不会从Inspector设置中自动填充,因为这些设置只在编辑器环境下与对象关联。
解决这个问题的一种方法是编写初始化代码,在运行时为新创建的对象手动设置必要的字段。或者,你可以考虑使用预制件(Prefabs),这样你就可以在编辑器中设置好对象的初始状态,并且在运行时实例化这些预制件,它们将保留其序列化状态。
理解这种行为差异对于有效地使用Unity非常重要,特别是在涉及动态生成内容和处理运行时数据的情况下。
解决
场景中一开始就存在的序列化变量不需要初始化,使用预制件创建的对象的序列化变量也不需要初始化。
最好的解决办法就是,不管是不是序列化对象,都进行下初始化。
异步加载的获取不安全风险
问题代码
功能
按键提醒,当角色走进碰撞体的时候会提醒玩家按下某个键位进行交互。
private void OnTriggerStay2D(Collider2D other)
{
if (other.gameObject.layer == LayerMask.NameToLayer("Player"))
{
//可互动的情况下
if (!DataMgr.GetInstance().fsm.Interacting )
{
if(!Enter)
Enter = true;
//显示UI
if (UIMgr.GetInstance().GetPanel<SuggestButtonPanel>("SuggestButtonPanel") == null)
{
UIMgr.GetInstance().AsyncGetObj<SuggestButtonPanel>("SuggestButtonPanel", 0, E_UI_Layer.Mid, (panel) =>
{
panel.SetSuggest("E","阅读");
});
}
}
//不可互动的情况下,如有UI,进行隐藏
else
{
if(Enter)
Enter = false;
if (UIMgr.GetInstance().GetPanel<SuggestButtonPanel>("SuggestButtonPanel") != null)
{
UIMgr.GetInstance().HidePanel("SuggestButtonPanel");
}
}
}
}
问题
经常会发现角色进入的时候代码提示UI显示了两次的警告
解析
原因是采用AsyncGetObj异步加载UI对象,加载完毕后才会把UI塞进字典,Getpanel随后就可以通过字典获取。
而在异步加载对象的时间差中,由于这个检测是持续性的,导致可能多次进行了Getpanel判定依然为空,导致多次执行了AsyncGetObj。
解决
使用同步加载,或者多采用一个bool值来参与判定
心得
当面临高频率执行的函数的时候,不要试图将需要借助异步加载本身的结果作为判定条件。
Unity的Invoke有滞后性
问题代码
主要
/// <summary>
/// 读取能力
/// </summary>
public void Load()
{
playerEnhancementList = JsonMgr.GetInstance().LoadData<List<string>>("PlayerEnhancement");
enhancementMethod.load = true;
foreach (var str in playerEnhancementList)
{
Enhancement enhancement= ResMgr.GetInstance().Load<ScriptableObject>(Consts.ScriptableData + "EnhancemenData/" + str) as Enhancement;
Invokemethond(enhancement.enhancementMethodName);
}
enhancementMethod.load = false;
}
Invokemethond
/// <summary>
/// 执行对应方法名
/// </summary>
/// <param name="enhancementMethodName">方法名</param>
private void Invokemethond(string enhancementMethodName)
{
enhancementMethod.Invoke(enhancementMethodName,0);
}
问题
foreach中的Invokemethond执行于enhancementMethod.load = false;之后,让enhancementMethod.load = true;无效。
解析
协程问题的特化版。
Unity的Invoke是协程的简化版,本质上是一个异步对象,这会导致他在函数中的执行是滞后的,可能整个函数执行完毕它所在的协程还没有执行完毕。
load函数是同步执行的,它会在主线程上按照代码的顺序执行。当它调用invoke方法时,它会开启一个协程来异步执行指定的方法,但是不会等待协程的执行结果,而是继续执行load函数的后续代码。所以,当load函数执行完毕时,协程可能还没有执行完毕
解决
不立刻让enhancementMethod.load = false;
而是新增能力的时候在enhancementMethod.load = false;
TimeLine系统的坑——依然属性冻结
问题
使用Timeline并且将某个Timeline的Warp Mode设置为Hold模式(对象停留在最后一帧),会发现Timeline里面调用到的对象属性都无法更改了(和Animation类似)。
解决办法
1.Warp Mode设置为None,但是建议此模式要在播放的末尾为你想要的动画播放完毕效果调用代码设置好。
2.推荐方法:对Timeline对象调用stop方法,你可以自由的选择退出冻结的时机。
使用触发器检测法进行帧攻击的时候不够灵敏
问题
使用Animator搭配触发器失活和激活来检测帧攻击是个常见做法,但是这么做有时候会发生时灵时不灵的现象。
原因
使用触发器和碰撞体的时候,采用的是物理运算,循环是在FixedUpdate中,Animator的帧循环是在Update中。
所以当攻击动画和碰撞体的激活时间不同步,就会导致攻击不触发。
解决办法
1.打开Project Settings面板里的Time,调低默认的Fixed Timestep,通常0.01够用。
2.推荐方法:不使用Unity原生的触发器,直接用代码采取射线检测Physics来进行伤害判定,此方法可塑性更高检测更加灵敏,是高频率交互物理游戏的优秀选择。
唯一的缺点就是上手门槛较高,可能需要造一套自己的射线检测模块。
项目完成日期
2024年1月11日 : 17 28