文章目录
观前提醒
由于代码真的是太多,又被作者重构多次,各位可以根据下面的链接和文章有需自取。
链接地址
油管主(作者):Barden
原视频连接
https://www.youtube.com/watch?v=Pux1GlFwKPs&list=PLy78FINcVmjA0zDBhLuLNL1Jo6xNMMq-W&index=1
原视频简介:
Discord Server:
https://discord.gg/uHQrf7K
Assets:
https://drive.google.com/drive/mobile/folders/1X_BGNUa75INjJRm0G0sEFd6o8E4Z8N8U?usp=sharing
国人制作的笔记与机翻视频(B站UP主勿杉)
教程素材、问题、笔记、源码
https://wuushan-public-content.notion.site/2D-f5b04930fa9c40468f360587b14871d0
https://www.bilibili.com/video/BV1ot4y1478z
百度网盘(第3、4、5章的Asset
内容)
链接:https://pan.baidu.com/s/1peF7GM6MKpGWrfI16qI0BA
提取码:o8mp
为什么要弄这篇博客?
勿杉UP写的笔记图片不能显示,不方便查看操作,所以我经过他的同意,在CSDN进行一次重述和内容补充。
章节内容提示
- 第1到2章没必要拘泥于代码,要学的是它实现功能的思路和熟悉unity各个功能模块,避免入坑和浪费时间。
- 第3到5章则在看视频的基础上结合我给的脚本链接,视频中的多数内容都是在讲如何重构有限状态机。
本人制作的环境
- unity版本:2022.3.5
- Microsoft Visual Studio Community 2022 (64 位)
- .NET Framwork 4.8
第1章:玩家控制器
Part1:设置瓦片地图与分类层的顺序
这里我们不了解Unity 的界面 其实问题也不大,后续都会有图文解释,但是我们先需要做一个约定。
Tab、选项卡、标签页都是一个东西
Player、角色、玩家都指同一个游戏对象
许多游戏会有一个更细致,更复杂的地图分层结构,其中专门会有一个碰撞层,是用来放置表示碰撞体形状的素材,然后可能在sprite editor
中涉及到一个不规则形状的碰撞调整,不过由于这里的素材都是较为规则,所以按下文内容操作即可。
【1】导入素材
我们先要下载好素材先,点击notion
笔记里面的素材地址,然后进行下载(建议提前了解别人的环境再导入资源)
接着导入素材操作
导入以下的文件夹内容:
图片素材/Player/Old
下的Idel,Jump,Wall,Wall Slide这四张png图片图片素材/Maps
下的所有内容
后续我们要编辑精灵图都基本是下图的操作。
【2】制作瓦片地图
单击这些素材文件,然后到Inspector
栏下,点击Sprite Editor
按钮,编辑2D精灵图,它们设置的值如下:
Tile Set.png
Idel,Jump,Wall,Wall Slide.png
也是同样操作,值要设置成X:32,Y:32
,就是要确定切割的瓦片的长宽,后续都是自行判断。
Decor Items.png
图片的值,要按如下这样设置,是为了避免版本不一致,导致后续游戏对象在Scene
窗口显示的图片大小的问题,同时要注意Pivot
的选择,精灵的中心点在原始纹理的 Rect 中的位置
【3】调色盘与瓦片存放操作
打开(如果没有,就到package中搜索,再install,一般都是默认安装好的)
然后创建调色盘,自行找文件夹位置
将Decor Items.png
与Tile Set.png
按图操作,然后保存到Assets/Tile Map/Assets
中,这个文件夹里面就只会保存切割好的精灵图了。
补充:
留意自己是否需要做到类似《饥荒》的一个视角变化,如果需要,那么人物移动到树下会不会被遮挡的问题需要考虑,这时unity的Y轴渲染和相应的调整锚点和设置pivot等等操作都需要完成。
【4】在 Hierarchy の Tilemap
创建游戏对象GameObject
游戏对象包含在下图,后续要铺设瓦片,都需要注意先点击对应的游戏对象,具体布局规则可看part1的【5】:
按照游戏对象的名称对应修改Soring Layer
,而我们后面的操作都离不开这Tags and Layers 的关键操作,要保证再sprite render
组件进行下图的一个操作。
所有的排序图层如下:
【游戏设计】为什么要让草地的图层排在玩家对象前面?
因为要突出草地,同时加上在摄像机的观察下,游戏显示效果更好,几乎所有的2D平台类的游戏都会这样操作。
【5】快捷键与最终成品
最终Part1部分,点击Tile Platte标签页,对瓦片按照下图进行关卡设计,其中的快捷键操作提升如下:
Shift+[
对瓦片地图进行左右选择,即Y轴对称Shift+]
对瓦片地图进行上下选择,即X轴对称[
与]
键则是顺时针与逆时针旋转
自己看需要设置地图的转角部分。
后续瓦片地图操作完成就可以关闭Tile Platte
的标签页了。
Part2:移动和跳跃
【1】给游戏游戏对象添加碰撞体
正常地给我们玩家对象添加碰撞体和刚体,模拟物理效果。
由于我们要控制Player
游戏对象进行一系列涉及到物理模拟的操作,所以为了不让其在移动的途中出现翻转,要冻结旋转,参考文档Rigidbody-freezeRotation - Unity 脚本 API,具体操作如下图:
然后就是给我们瓦片地图添加刚体和碰撞体,注意需要加的组件有Tilemap Collider2D
和Composite Collider2D
,其中的刚体组件,unity
会自行判断并添加上。另外下图操作的作用:
瓦片地图 2D 碰撞体 (Tilemap Collider 2D) - Unity 手册
2D 复合碰撞体 (Composite Collider 2D) - Unity 手册
Composite Collider2D
组件可以将多个瓦片变成一个整体,这也是为什么在Scene
窗口中,显示瓦片与瓦片之间的网格消失掉了,但是相对的瓦片内部也是可以任由游戏对象进行任何空间上的操作,因此该组件只是作用于瓦片地图的暴露再外的边缘。
为什么要设置瓦片地图的刚体为静态,而玩家的刚体组件为动态
TIP:自行深入了解这BodyType选项的区别。
文档内容:
Static 2D 刚体设计为在模拟条件下完全不动;如果任何对象与 Static 2D 刚体碰撞,此类型刚体的行为类似于不可移动的对象(就像具有无限质量)。此刚体类型也是使用资源最少的刚体类型。Static 刚体只能与 Dynamic 2D 刚体碰撞。不支持两个 Static 2D 刚体进行碰撞,因为这种刚体不是为了移动而设计的。
A Static Rigidbody 2D is designed to not move under simulation at all; if anything collides with it, a Static Rigidbody 2D behaves like an immovable object (as though it has infinite mass). It is also the least resource-intensive body type to use. A Static body only collides with Dynamic Rigidbody 2Ds. Having two Static Rigidbody 2Ds collide is not supported, since they are not designed to move.
效果如下:
【2】角色的移动和跳跃功能(基本原理)
实现移动:
- 在每帧更新中,获取水平输入值,通常使用
Input.GetAxisRaw("Horizontal")
。 - 根据输入值和移动速度,计算出水平移动的速度。这是你想要将角色移动的方向和速度。
- 使用 Rigidbody2D 组件,将计算出的水平速度分配给角色的刚体,这将使角色沿水平方向移动。
示例代码片段:
float movementInputDirection = Input.GetAxisRaw("Horizontal");
Vector2 movement = new Vector2(movementInputDirection * movementSpeed, rigidbody2D.velocity.y);
rigidbody2D.velocity = movement;
实现跳跃:
- 检查玩家是否按下跳跃键(例如,空格键)。
- 如果玩家按下了跳跃键,为角色的垂直速度(通常是 Y 轴速度)添加一个跳跃力(jumpForce)。
- 这将使角色向上跳跃。
示例代码片段:
if (Input.GetButtonDown("Jump"))
{
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, jumpForce);
}
涉及到的问题:为什么打包游戏,会出现“The type or namespace name UnityEditor could not be found. Areyoumissing a using directiveor an assembly reference?”
错误?
如果当各位打开自己脚本代码时,存在一个Tilemaps
的引用,而根据using UnityEditor引用注意事项_差点忘记打铁了的博客-CSDN博客这篇文章,可知
因为Unity在发布游戏的时候不会使用
UnityEditor
命名空间的程序集UnityEditor.dll
,自然就不能识别UnityEditor
命名空间了。所以各位需要注意对代码中的
UnityEditor.Tilemaps
进行注释,反正实际的运行也不会启用。
数值自行调整,控制Player游戏对象的跳跃受力,测试查看具体的跳跃效果。
【3】Cinemachine
插件:实现跟随角色镜头
如果是观看视频的朋友,参考这篇CSDN的文章内容解决Unity导入Cinemachine插件后不显示在菜单栏而是显示在Gameobject栏的问题_cinemachine不在菜单栏_master_yi_的博客-CSDN博客,如果你的Unity版本跟我一样,那么下载插件,untiy2022平台对应2.9.7版本:
新建这个游戏对象,命名为Player Camera
。
值就如下图设置,然后空对象Cameras
下绑定多个相关有关镜头的子对象,Main Camera
可以在cinemachine
插件的帮助下,实现我们摄像头跟随Player
游戏对象的目标。
问题:Cinemachine实现跟随角色镜头时,角色移动产生画面抖动现象?
解决方案:
在part2的【2】中涉及到的脚本,由于我们已经实现了角色的移动是在
FixedUpdate()
这个生命周期内进行,所以就只用在Main Camera
中的Cinemachine Brain
将Update Method
更改为Fixed Update
可以解决Player
移动发生的抖动。
实际上我没看到在unity2022
有这个问题,就算是默认的Smart Update
也不可能发生抖动了,而这类问题根据网上的信息来看,更多发生在2021
版本(亲自试过还真是这样!)。
问题:Cinemachine实现跟随角色镜头时,出现白线和黑线问题?
【4】拓展
①Unity在VS中没有代码提示的问题(代码自动补全问题)
Unity在VS中没有代码提示的问题_vs2019没有代码提示了_菊头蝙蝠的博客-CSDN博客
②我走过的坑:不同版本的unity打开同一个项目
我之前分不清抖动与地图抗锯齿线的问题,纠结了半天,于是就手贱换到unity 2021
平台进行测试,结果就报了An error occurred while resolving packages
的错误,之所以产生这样的原因是因为unity是向下兼容部分版本的,即只能让unity2022
平台打开用unity2021
完成的项目,而不能反过来,其他版本暂时未试过。
解决方案:看这篇Unity项目报错_an error occurred while resolving packages: one or_夏炎黄的博客-CSDN博客,但是我不建议这样干!因为要重新下载对应unity版本的插件,如果是在公司用,应该明天就要滚了吧,个人就无所谓,毕竟可以重新下嘛。
③可以添加cinemachine的完美像素做到一个对不同分辨率的镜头效果。
④在不同scene场景地图,会额外添加一个多边形碰撞体Polygon Collider2D
(记得trigger)的游戏对象,这个碰撞体的作用就是为了让Cinemachine Confiner
扩展工具可以跨场景添加不同地图scene的边界,做到不移出场景,下面的是挂载到我们包含cinemachine组件的游戏对象上的脚本函数(注意生命周期调用)。
private void SwitchConfinerShape()
{
PolygonCollider2D confinerShape = GameObject.FindGameObjectWithTag("BoundsConfiner").GetComponent<PolygonCollider2D>();
CinemachineConfiner confiner = GetComponent<CinemachineConfiner>();
confiner.m_BoundingShape2D = confinerShape;
//Call this if the bounding shape's points change at runtime
confiner.InvalidatePathCache();
}
Part3:动画,地面检测与跳跃次数
【1】Player的Idle状态的动画
在part2的视频中,我们要实现Player
的Idle
动画,先打开并创建动画与动画控制器的标签页
接着我们在文件夹中,新建对应的动画控制器
点击我们的游戏对象,然后到Animation
标签页,制作动画的方式如下,跟着下图创建动画状态比在unity的project标签页中右键创建会方便操作的多。
给Player
这个游戏对象 添加对应属性,选择它应有的动画控制器,后续的操作都不会啰嗦提示。
打开Animation Controller
标签页,我们设置动画状态机图应该是这样的
效果如下:
问题①:在unity
哪里找到Smaples
选项,从而可以调整人物的动画时间间隔?
在Animation
标签页,注意要先点击动画状态才会自动显示。
问题②:手贱搞动画时,遇到错误: Animation AnimationEvent has no function name specified!
解决方案:Unity3d Animation AnimationEvent has no function name specified!_一笑傲王侯的博客-CSDN博客
接上操作,同样的步骤可以制作Walk
动画状态,经过个人的测试,发现Idle状态的Sample Rate
为12
,而Walk
状态为30
,动画效果会更好,当然做到下图的效果还需要后续的努力。
我们继续对Player游戏对象进行操作,完成跳跃的动画状态,方便后续的混合树。
【2】动画状态切换与条件判断
首先我们在Animator标签页中创建三个参数用于后续的条件判断,其中当Player跳跃时yVelocity
为正,下落为负
在进行动画状态切换,拉箭头(make transition
)之前,我们先要了解官方文档动画过渡 - Unity 手册 (unity3d.com)的内容,知道后续的一个基本操作。
参考文档混合树 - Unity 手册 (unity3d.com)的内容,__混合树__允许通过不同程度合并多个动画来使动画平滑混合,而它就很适合因为yVelocity
的一个变化,展示跳跃动画状态从起跳到落地,所以我们需要在Animator
标签页创建一个混合树,然后如下图设置即可。
最终的一个动画状态机如下图所示:
【3】C# 核心代码,地面检测与修复无限跳跃
PlayerController.cs
代码中//...
表示承接上文的代码,如果出现了函数则表示替换/添加。
// 玩家控制器类,继承自MonoBehaviour
public class PlayerController : MonoBehaviour
{
private void Start()
{
//...
animator = GetComponent<Animator>(); // 获取动画控制器组件
amountOfJumpsLeft = amountOfJumps; // 初始化剩余可跳跃次数
}
private void Update()
{
//...
UpdateAnimations(); // 更新动画状态
CheckIfCanJump(); // 检查是否可以跳跃
}
private void FixedUpdate()
{
//...
CheckSurroundings(); // 检查周围环境
}
private void Jump()
{
// 跳跃方法
if (canJump)
{
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, jumpForce); // 设置刚体的Y轴速度实现跳跃
amountOfJumpsLeft--; // 跳跃次数减少
}
}
private void CheckMovementDirection()
{
//...
if(rigidbody2D.velocity.x != 0)
{
isWalking = true; // 如果刚体X轴速度不为0,表示在行走
}
else
{
isWalking = false; // 否则不在行走
}
}
// 检查周围环境是否在地面上
private void CheckSurroundings()
{
isGrounded = Physics2D.OverlapCircle(groundCheck.position, groundCheckRadius, whatIsGround);
}
// 更新动画状态
private void UpdateAnimations()
{
animator.SetBool("isWalking", isWalking); // 更新动画参数isWalking
animator.SetBool("isGrounded", isGrounded); // 更新动画参数isGrounded
animator.SetFloat("yVelocity", rigidbody2D.velocity.y); // 更新动画参数yVelocity
}
// 在编辑器中绘制地面检测点的圆形Gizmos
private void OnDrawGizmosSelected()
{
Gizmos.DrawWireSphere(groundCheck.position, groundCheckRadius);
}
// 检查是否可以跳跃
private void CheckIfCanJump()
{
if(isGrounded && rigidbody2D.velocity.y <= 0)
{
amountOfJumpsLeft = amountOfJumps; // 在地面且Y轴速度小于等于0时,重置剩余可跳跃次数
}
if(amountOfJumpsLeft <= 0)
{
canJump = false; // 如果剩余可跳跃次数小于等于0,则无法再跳跃
}
else
{
canJump = true; // 否则可以跳跃
}
}
}
奇怪的BUG:自由落体后,rigidbody.velocity.x
在Update生命周期内出现一个极大负值?
具体画面表现,启动游戏的几分秒内,Player
游戏对象就会先出现Walk
动画状态,而这种情况只当游戏对象自由落体后就会产生。
经过我跟ChatGPT的探讨,它并不觉得代码存在问题,因为rigidbody.velocity.x
由movementSpeed
和movementInputDirection
决定,而我DEBUG后发现方向值是固定为0
(右为1,左为-1)的,因此如果你也出现了这样的BUG,那么就跟我一样,修改CheckMovementDirection()
函数中isWalking
的条件判断即可。
if (movementInputDirection != 0) isWalking = true;
else isWalking = false;
而我目前也暂时没有从官方文档2D 刚体 - Unity 手册 (unity3d.com)中得到任何启示。
或者修改成如下的写法:
if (Mathf.Abs(rigidbody2D.velocity.x)>=0.01f)
{
isWalking = true;
}
else
{
isWalking = false;
}
【4】unity操作:
根据上述代码OnDrawGizmosSelected()
函数,首先对我们的平台进行图层标记,以让Player
游戏对象可以地面检测。
文档Gizmos-DrawWireSphere - Unity 脚本 API教我们如何绘制圆形,然后就是让Player
作为父对象,新建一个用于地面检测子空对象,它将作为transform
得到一个检测圆形的半径,也即是物理射线检测,后面诸多的检测墙面和边角,判断敌人都离不开它。
【5】拓展
①额外了解
Animator - Unity 脚本 API
②Inspector面板的右上角三点打开,选择Debug可以显示私有变量、组件实例化编号等详细信息。
③遗留问题:1次跳跃情况下的良性bug?
问题原因:因为没有给刚体组件添加2D物理材质
给我们Player游戏对象的刚体组件添加物理材质,我看了下文档2D 物理材质 - Unity 手册 (unity3d.com),摩擦力的用处是在于基于给予一个物体力的矢量操作时,才能发挥作用,而目前都是修改Vector2
空间位置,加了摩擦力反而会造成物体不能移动的问题,所以记得设为0。
Part4+part5:土狼时间,滑墙和跳墙
【1】C#脚本
PlayerController.cs
public class PlayerController : MonoBehaviour
{
//...
// 定义变量和属性
public Transform wallCheck; // 用于检测是否接触墙壁的射线点
private bool isTouchingWall; // 是否接触墙壁
public float wallCheckDistance; // 检测墙壁的距离
private bool isWallSliding; // 是否正在墙壁滑动
public float wallSlideSpeed; // 墙壁滑动速度
public float movementForceInAir = 50f; // 空中移动的力大小
public float airDragMultiplier = 0.9f; // 空中阻力乘数
public float variableJumpHeightMultiplier = 0.5f; // 可变跳跃高度的乘数
public Vector2 wallHopDirection; // 蹬墙壁方向
public Vector2 wallJumpDirection; // 墙壁跳跃方向
public float wallHopForce; // 蹬墙壁力大小
public float wallJumpForce; // 墙壁跳跃的力大小
private int facingDirection = 1; // 角色朝向
// 在脚本启动时执行
private void Start()
{
// 初始化墙壁跳跃方向
wallHopDirection.Normalize();
wallJumpDirection.Normalize();
}
// 在每一帧更新时执行
private void Update()
{
// 检测是否正在墙壁滑动
CheckIfWallSliding();
}
// 检测玩家输入
private void CheckInput()
{
//...
// 检测跳跃松开输入(土狼时间),还要考虑空气阻力、重力等情况,具体看ApplyMovement
if (Input.GetButtonUp("Jump"))
{
// 根据可变跳跃高度乘数调整垂直速度
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, rigidbody2D.velocity.y * variableJumpHeightMultiplier);
}
}
// 翻转角色朝向
private void Flip()
{
if (!isWallSliding)
{
// 改变角色朝向
facingDirection *= -1;
//...
}
}
// 更新动画状态
private void UpdateAnimations()
{
//...
animator.SetBool("isWallSliding", isWallSliding);
}
// 检测周围环境
private void CheckSurroundings()
{
//...
// 发射射线检测是否接触墙壁
isTouchingWall = Physics2D.Raycast(wallCheck.position, transform.right, wallCheckDistance, whatIsGround);
}
// 在编辑模式下绘制场景Gizmos
private void OnDrawGizmosSelected()
{
//...
// 绘制墙壁检测射线
Gizmos.DrawLine(wallCheck.position, new Vector3(wallCheck.position.x + wallCheckDistance, wallCheck.position.y, wallCheck.position.z));
}
// 检测是否正在墙壁滑动
private void CheckIfWallSliding()
{
// 如果接触到墙壁,不在地面上,并且垂直速度小于0,则正在墙壁滑动
if (isTouchingWall && !isGrounded && rigidbody2D.velocity.y < 0)
{
isWallSliding = true;
}
else
{
isWallSliding = false;
}
}
// 应用角色移动
private void ApplyMovement()
{
// 如果不在地面上、不在墙壁滑动状态且没有水平输入,则应用空中阻力
if (!isGrounded && !isWallSliding && movementInputDirection == 0)
{
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x * airDragMultiplier, rigidbody2D.velocity.y);
}
else
{
// 在地面上或墙壁滑动状态下,应用水平移动速度
rigidbody2D.velocity = new Vector2(movementSpeed * movementInputDirection, rigidbody2D.velocity.y);
}
// 如果正在墙壁滑动,限制垂直速度
if (isWallSliding)
{
if (rigidbody2D.velocity.y < -wallSlideSpeed)
{
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, -wallSlideSpeed);
}
}
}
// 角色跳跃
private void Jump()
{
if (canJump && !isWallSliding)
{
// 应用跳跃力,并减少可跳跃次数
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, jumpForce);
amountOfJumpsLeft--;
}
else if (isWallSliding && movementInputDirection == 0 && canJump)
{
// 在墙壁滑动时,进行墙壁跳跃
isWallSliding = false;
amountOfJumpsLeft--;
Vector2 forceToAdd = new Vector2(wallHopForce * wallHopDirection.x * -facingDirection, wallHopForce * wallHopDirection.y);
rigidbody2D.AddForce(forceToAdd, ForceMode2D.Impulse);
}
else if ((isWallSliding || isTouchingWall) && movementInputDirection != 0 && canJump)
{
// 在墙壁滑动或接触墙壁状态下,进行墙壁跳跃
isWallSliding = false;
amountOfJumpsLeft--;
Vector2 forceToAdd = new Vector2(wallJumpForce * wallJumpDirection.x * movementInputDirection, wallJumpForce * wallJumpDirection.y);
rigidbody2D.AddForce(forceToAdd, ForceMode2D.Impulse);
}
}
// 检测是否能够跳跃
private void CheckIfCanJump()
{
if ((isGrounded && rigidbody2D.velocity.y <= 0) || isWallSliding)
{
// 如果在地面上并且垂直速度小于等于0,或者正在墙壁滑动,则重置可跳跃次数
amountOfJumpsLeft = amountOfJumps;
}
//...
}
}
问题:下面为视频Part4
的脚本,它与上面本章的part4
在Applymovement()
中在对移动判断的区别:
函数 | 本章 | 视频 |
---|---|---|
结构与可读性 | 代码简洁,易读 | 条件语句较多,可读性稍差 |
性能 | 较少使用物理引擎操作,性能可能稍好 | 使用了AddForce ,可能对性能有微小影响 |
维护性 | 简单逻辑,易于维护 | 多条件语句,需要更多注意维护 |
扩展性 | 功能较简单,扩展性有限 | 使用AddForce ,具备更大的功能扩展性 |
选择哪个函数更好取决于你的需求和项目规模。如果项目相对简单,强调代码的可读性和维护性,本章可能更适合。如果你希望拥有更多的物理特性和灵活性,视频的可能更合适。
private void ApplyMovement()
{
// 应用角色的移动逻辑
// 如果角色在地面上
if (isGrounded)
{
// 设置角色的水平速度,保持垂直速度不变
rigidbody2D.velocity = new Vector2(movementSpeed * movementInputDirection, rigidbody2D.velocity.y);
}
// 如果角色不在地面上,没有贴墙滑动,并且有水平输入
else if (!isGrounded && !isWallSliding && movementInputDirection != 0)
{
// 计算要添加的空中水平力
Vector2 forceToAdd = new Vector2(movementForceInAir * movementInputDirection, 0);
rigidbody2D.AddForce(forceToAdd);
// 如果水平速度超过最大移动速度,限制水平速度
if (Mathf.Abs(rigidbody2D.velocity.x) > movementSpeed)
{
rigidbody2D.velocity = new Vector2(movementSpeed * movementInputDirection, rigidbody2D.velocity.y);
}
}
// 如果角色不在地面上,没有贴墙滑动,并且没有水平输入
else if (!isGrounded && !isWallSliding && movementInputDirection == 0)
{
// 应用空气阻力来减缓水平速度
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x * airDragMultiplier, rigidbody2D.velocity.y);
}
// 如果角色正在贴墙滑动
if (isWallSliding)
{
// 如果垂直速度过快,限制垂直速度
if (rigidbody2D.velocity.y < -wallSlideSpeed)
{
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, -wallSlideSpeed);
}
}
}
【2】滑墙动画
在Animation
标签页中,将图片素材/Player/Old/Wall Slide.png
图片按之前的步骤切割处理成sprite
图,之后再新建动画状态,拖拽图片到下图标签页,就变成动画。
点击Player
游戏对象先,再到在Animator
标签页,先自行设置布尔参数isWallSliding
,它的动画状态机如下图所示(本章后续的步骤不少都是重复的,我不会太多图文内容,会将更多的精力放在新知识上):
bug问题:没有设置正确动画的状态切换条件
操作:跳上墙,然后按住右键,在接近地面后就按住左键
【3】unity操作
首先跟之前的地面检测一样的操作,新建用于墙面检测的transform
。
然后按住下图的指示操作,另外一定要注意调整后续涉及到的检测线的位置,如果位置不对,那么会出现很严重的问题。
设置如下的数值,就可以进行测试了。
【4】拓展
①土狼时间是什么?为什么我们要在平台动作类游戏中这样设计。
https://game.academy.163.com/course/careerArticle?course=517
这里我顺便推荐一个有关独立游戏制作的优质平台给各位,它会用C#实现比本章更加详细的土狼时间。
https://indienova.com/indie-game-development/input-buffering-and-coyote-time/
②OnDrawGizmosSelected()
与 OnDrawGizmos()
的区别
在unity编辑器点击
Gizmos
操作后,一个显示操作麻烦了点(如果存在父子关系则选择父类游戏对象),另一个则是不选择对应的游戏对象也能自行显示。
Part6:改进跳墙逻辑判断
【1】C#脚本
PlayerController.cs
它的主要操作就是拆分跳跃的逻辑,增加了判断和检测条件
布尔变量
canJump
变为到底是平台跳跃NormalJump
和墙面跳跃WallJump
Jump()
函数变为NormalJump()
和WallJump()
,考虑计时器对跳跃的影响(实际的跳墙操作我并不觉得好)
public class PlayerController : MonoBehaviour
{
//...
private float jumpTimer; // 跳跃计时器,用于控制跳跃时机
public float jumpTimerSet = 0.15f; // 跳跃计时器的初始值
private bool isAttemptingToJump; // 是否正在尝试跳跃
private bool canNormalJump; // 是否能够普通跳跃
private bool canWallJump; // 是否能够墙壁跳跃
private bool checkJumpMultiplier; // 是否需要检查跳跃倍数
private bool canMove; // 是否能够移动
private bool canFlip; // 是否能够翻转
private float turnTimer; // 翻转计时器,用于控制翻转延迟
public float turnTimerSet = 0.1f; // 翻转计时器的初始值
private float wallJumpTimer; // 墙壁跳跃计时器,用于控制墙壁跳跃后的无敌时间
public float wallJumpTimerSet = 0.5f; // 墙壁跳跃计时器的初始值
private bool hasWallJumped; // 是否已经执行了墙壁跳跃
private int lastWallJumpDirection; // 上一次墙壁跳跃的方向
private void Update()
{
//...
CheckJump();// 在每帧更新时检查跳跃
}
private void CheckInput()
{
// 检查输入,包括移动和跳跃
movementInputDirection = Input.GetAxisRaw("Horizontal"); // 获取水平移动输入
if (Input.GetButtonDown("Jump")) // 检测是否按下跳跃按钮
{
if (isGrounded || (amountOfJumpsLeft > 0 && isTouchingWall)) // 当在地面上或者还有剩余跳跃次数且贴着墙壁时
{
NormalJump(); // 执行普通跳跃
}
else
{
jumpTimer = jumpTimerSet; // 否则,开始进行长按跳跃计时
isAttemptingToJump = true; // 标记为正在尝试跳跃
}
}
if (Input.GetButtonDown("Horizontal") && isTouchingWall) // 当按下水平方向按钮且贴着墙壁时
{
if (!isGrounded && movementInputDirection != facingDirection) // 当不在地面上且输入方向与面朝方向不一致时
{
canMove = false; // 禁止移动
canFlip = false; // 禁止翻转
turnTimer = turnTimerSet; // 启动翻转延迟计时
}
}
if (!canMove) // 如果不能移动
{
turnTimer -= Time.deltaTime; // 计时器递减
if (turnTimer <= 0) // 当计时器归零
{
canMove = true; // 允许移动
canFlip = true; // 允许翻转
}
}
if (checkJumpMultiplier && !Input.GetButton("Jump")) // 如果需要检查跳跃倍数且未按下跳跃按钮
{
checkJumpMultiplier = false; // 关闭检查跳跃倍数的标志
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, rigidbody2D.velocity.y * variableJumpHeightMultiplier); // 对垂直速度进行跳跃倍数调整
}
}
private void CheckJump()
{
if (jumpTimer > 0) // 如果跳跃计时器大于0
{
if (!isGrounded && isTouchingWall && movementInputDirection != 0 && movementInputDirection != facingDirection) // 如果不在地面上,贴着墙壁,并且有水平输入
{
WallJump(); // 执行墙壁跳跃
}
else if (isGrounded) // 否则,如果在地面上
{
NormalJump(); // 执行普通跳跃
}
}
if (isAttemptingToJump) // 如果正在尝试跳跃
{
jumpTimer -= Time.deltaTime; // 跳跃计时器递减
}
if (wallJumpTimer > 0) // 如果墙壁跳跃计时器大于0
{
if (hasWallJumped && movementInputDirection == -lastWallJumpDirection) // 如果已经执行了墙壁跳跃且水平输入与上次墙壁跳跃方向相反
{
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, 0.0f); // 停止垂直速度
hasWallJumped = false; // 重置墙壁跳跃标志
}
else if (wallJumpTimer <= 0) // 否则,如果墙壁跳跃计时器归零
{
hasWallJumped = false; // 重置墙壁跳跃标志
}
else
{
wallJumpTimer -= Time.deltaTime; // 墙壁跳跃计时器递减
}
}
}
private void NormalJump()
{
if (canNormalJump) // 如果可以普通跳跃
{
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, jumpForce); // 对垂直速度进行普通跳跃力的设置
amountOfJumpsLeft--; // 跳跃次数减少
jumpTimer = 0; // 跳跃计时器归零
isAttemptingToJump = false; // 重置跳跃尝试标志
checkJumpMultiplier = true; // 启动检查跳跃倍数
}
}
private void WallJump()
{
if (canWallJump) // 如果可以墙壁跳跃
{
rigidbody2D.velocity = new Vector2(rigidbody2D.velocity.x, 0.0f); // 停止垂直速度
isWallSliding = false; // 关闭墙壁滑行标志
amountOfJumpsLeft = amountOfJumps; // 重置剩余跳跃次数
amountOfJumpsLeft--; // 跳跃次数减少
Vector2 forceToAdd = new Vector2(wallJumpForce * wallJumpDirection.x * movementInputDirection, wallJumpForce * wallJumpDirection.y); // 计算墙壁跳跃的力
rigidbody2D.AddForce(forceToAdd, ForceMode2D.Impulse); // 施加力进行墙壁跳跃
jumpTimer = 0; // 跳跃计时器归零
isAttemptingToJump = false; // 重置跳跃尝试标志
checkJumpMultiplier = true; // 启动检查跳跃倍数
turnTimer = 0; // 翻转计时器归零
canMove = true; // 允许移动
canFlip = true; // 允许翻转
hasWallJumped = true; // 设置墙壁跳跃标志
wallJumpTimer = wallJumpTimerSet; // 设置墙壁跳跃计时器
lastWallJumpDirection = -facingDirection; // 更新上次墙壁跳跃方向
}
}
private void Flip()
{
if (!isWallSliding && canFlip) // 如果不在墙壁滑行状态且可以翻转
{
// ...
}
}
private void CheckIfCanJump()
{
if (isGrounded && rigidbody2D.velocity.y <= 0.01f) // 如果在地面上且垂直速度接近零
{
amountOfJumpsLeft = amountOfJumps; // 重置剩余跳跃次数
}
if (isTouchingWall) // 如果贴着墙壁
{
canWallJump = true; // 允许墙壁跳跃
}
if (amountOfJumpsLeft <= 0) // 如果剩余跳跃次数小于等于零
{
canNormalJump = false; // 禁止普通跳跃
}
else
{
canNormalJump = true; // 允许普通跳跃
}
}
private void CheckIfWallSliding()
{
if (isTouchingWall && movementInputDirection == facingDirection && rigidbody2D.velocity.y < 0) // 如果贴着墙壁,输入方向与面朝方向相同且垂直速度小于零
{
isWallSliding = true; // 设置墙壁滑行标志
}
else
{
isWallSliding = false; // 关闭墙壁滑行标志
}
}
private void ApplyMovement()
{
if (!isGrounded && !isWallSliding && movementInputDirection == 0)
{
//...
}
else if(canMove)
{
//...
}
//...
}
}
【2】unity操作
对Player
游戏对象的设值,可以自行调整跳跃的力量、滑墙速度、重力等影响,查看具体效果。
Part7:平台攀爬和解决遗留Bug
【1】原理
平台攀爬的基本原理如下:
- 检测台阶探测点: 通过一个射线起点
ledgeCheck
来探测角色是否接触到台阶。当角色碰到墙壁,但没有碰到台阶时,记录下台阶底部的位置ledgePosBot
。 - 检测能否攀爬: 通过之前的墙面检测射线,当角色贴着墙壁,就会显示为
true
。如果之前没有检测到台阶并且此时发现了台阶(!ledgeDetected
),则将ledgeDetected
标志设置为true
,并记录台阶底部的位置。 - 开始攀爬: 一旦检测到台阶且没有进行过攀爬操作(
!canClimbLedge
),则将canClimbLedge
设置为true
,表示角色可以执行攀爬动作。根据角色的朝向,计算出两个可能的台阶顶部位置ledgePos1
和ledgePos2
。 - 禁止移动和翻转: 在进行攀爬时,设置
canMove
和canFlip
为false
,以防止角色在攀爬过程中移动或翻转。 - 更新攀爬的动画: 设置角色的动画状态,表示角色正在进行攀爬操作。
- 移动到台阶顶部位置: 将角色的位置移动到台阶顶部位置
ledgePos1
,以便角色完成攀爬。 - 攀爬完成: 当攀爬结束时,例如通过动画事件触发,将
canClimbLedge
设置为false
,允许角色恢复移动和翻转,然后将角色的位置移动到另一个台阶顶部位置ledgePos2
,以确保角色不再粘在台阶上。
【2】C#脚本
PlayerController.cs
public class PlayerController : MonoBehaviour
{
//...
public Transform ledgeCheck; // 检测台阶的射线起点
private bool isTouchingLedge; // 是否触碰到台阶
private bool canClimbLedge = false; // 是否可以攀爬台阶
private bool ledgeDetected; // 是否探测到台阶
private Vector2 ledgePosBot; // 台阶底部位置
private Vector2 ledgePos1; // 位置1
private Vector2 ledgePos2; // 位置2
// 攀爬台阶的偏移量
public float ledgeClimbXOffset1 = 0f;
public float ledgeClimbYOffset1 = 0f;
public float ledgeClimbXOffset2 = 0f;
public float ledgeClimbYOffset2 = 0f;
private void Update()
{
//...
CheckLedgeClimb(); // 检查攀爬台阶
}
// 检查玩家输入的函数
private void CheckInput()
{
//...
if (turnTimer >= 0) // 如果计时器大于等于0,进行一次性延迟操作
{
turnTimer -= Time.deltaTime; // 减少计时器值
if (turnTimer <= 0) // 当计时器归零时
{
canMove = true; // 允许移动
canFlip = true; // 允许翻转
}
}
//...
}
// 检查周围环境的函数
private void CheckSurroundings()
{
//...
// 使用射线探测是否碰到台阶
isTouchingLedge = Physics2D.Raycast(ledgeCheck.position, transform.right, wallCheckDistance, whatIsGround);
if (isTouchingWall && !isTouchingLedge && !ledgeDetected)
{
ledgeDetected = true;
ledgePosBot = wallCheck.position; // 记录台阶底部位置
}
}
// 检查是否正在墙壁滑动的函数
private void CheckIfWallSliding()
{
// 如果贴着墙壁、水平输入方向与朝向一致、垂直速度小于0且不能攀爬台阶
if (isTouchingWall && movementInputDirection == facingDirection && rigidbody2D.velocity.y<0 && !canClimbLedge)
//...
}
// 检查攀爬台阶的函数
private void CheckLedgeClimb()
{
// 如果探测到台阶且不能攀爬台阶
if (ledgeDetected && !canClimbLedge)
{
canClimbLedge = true; // 允许攀爬台阶
// 根据角色朝向确定台阶顶部位置1和位置2
if (isFacingRight)
{
ledgePos1 = new Vector2(Mathf.Floor(ledgePosBot.x + wallCheckDistance) - ledgeClimbXOffset1, Mathf.Floor(ledgePosBot.y) + ledgeClimbYOffset1);
ledgePos2 = new Vector2(Mathf.Floor(ledgePosBot.x + wallCheckDistance) + ledgeClimbXOffset2, Mathf.Floor(ledgePosBot.y) + ledgeClimbYOffset2);
}
else
{
ledgePos1 = new Vector2(Mathf.Ceil(ledgePosBot.x - wallCheckDistance) + ledgeClimbXOffset1, Mathf.Floor(ledgePosBot.y) + ledgeClimbYOffset1);
ledgePos2 = new Vector2(Mathf.Ceil(ledgePosBot.x - wallCheckDistance) - ledgeClimbXOffset2, Mathf.Floor(ledgePosBot.y) + ledgeClimbYOffset2);
}
canMove = false; // 禁止移动
canFlip = false; // 禁止翻转
animator.SetBool("canClimbLedge", canClimbLedge); // 设置角色动画状态
}
if (canClimbLedge)
{
transform.position = ledgePos1; // 攀爬时固定的位置1
}
}
// 攀爬台阶完成后的回调函数
public void FinishLedgeClimb()
{
canClimbLedge = false; // 禁止攀爬台阶
transform.position = ledgePos2; // 移动到台阶顶部位置2
canMove = true; // 允许移动
canFlip = true; // 允许翻转
ledgeDetected = false; // 重置台阶探测标志
animator.SetBool("canClimbLedge", canClimbLedge); // 设置角色动画状态
}
}
① Mathf.Floor
和Mathf.Ceil
是数值取整
https://blog.csdn.net/weixin_38211198/article/details/90489629。
【3】攀爬动画与添加事件(重复操作)
按照老步骤,自己把图拖拽到Assets
中,变成unity的内部资源,然后设置sprite
图的属性。
对我们的爬墙动画这样切割。
创建爬墙的动画状态,需要注意的是我们要自行每帧情况下,编辑碰撞体的变化。
接着我们设置的动画状态机如下图所示,而can Transition To Self
是必须要记得不勾选的:
【4】解决遗留Bug
①在滑墙阶段,Player准备跳墙时,动画状态出错
DEBUG发现
Player在翻转后,也满足了接触墙面和真正滑墙的情况
因此修改方案如下:
②攀爬阶段,游戏对象的位置不固定,出现自由下落等状况
解决方案:
③攀爬阶段,游戏对象穿墙,进入了地图内。
这是因为你没有设置攀爬完成后,玩家角色应该出现的位置。
④跳墙的手感非常差!
之所以出现这种情况,是因为我们在跳墙到滑墙阶段,没有强制在空中进行翻转,这个问题后续会在有限状态机中解决。
【5】拓展
不修改动画状态机的各种状态,最快捷修改动画的操作。
这里视频就选择把角色移动状态换成了跑步状态,我记得游戏设计中是这样说明的,就是避免玩家觉得场景枯燥和觉得画面卡顿。
Part8:冲刺与残影
【1】原理
玩家的冲刺与残影效果的实现原理如下:
-
玩家冲刺效果实现:
- 当玩家按下冲刺按钮时,检查冲刺冷却时间是否已过,并在条件满足时触发冲刺操作。
- 在冲刺操作中,玩家的速度被设置为一个较高的值,使其在短时间内快速移动。
- 冲刺过程中,更新玩家位置,同时生成玩家的残影效果,以模拟高速移动的轨迹。
-
残影效果实现:
- 创建一个对象池用于管理残影对象,初始时创建一定数量的残影对象。
- 在玩家冲刺时,从对象池中获取一个残影对象,并将其位置与玩家位置相同,显示在玩家后方。
- 残影对象的透明度逐渐减小,使得残影逐渐消失,营造出残影效果。
- 超过一定时间后,将残影对象放回对象池中以供下次使用。
-
对象池的作用:
- 对象池是一个预先创建并维护的对象集合,用于减少动态创建和销毁对象的开销。
- 在需要时,从对象池中获取闲置的对象,避免了频繁的内存分配和回收操作。
- 对象池能够提高性能,降低资源消耗,并且更加适用于需要频繁创建和销毁的对象,如残影效果
【2】C#脚本
PlayerController.cs
public class PlayerController : MonoBehaviour
{
//...
private bool isDashing;// 是否正在进行冲刺的标志
public float dashTime = 0.2f;// 冲刺持续时间
public float dashSpeed = 50f;// 冲刺速度
public float distanceBetweenImages = 0.1f;// 冲刺图像之间的距离
public float dashCoolDown = 0.2f;// 冲刺冷却时间
private float dashTimeLeft;// 剩余冲刺时间
private float lastImageXpos;// 上一个图像的X坐标位置
private float lashDash = -100f;// 上次冲刺的时间
private void Update()
{
//...
CheckDash();// 调用检查冲刺的方法
}
private void CheckInput()
{
//...
if (Input.GetButtonDown("Dash"))
{
// 如果当前时间大于上次冲刺时间加上冲刺冷却时间
if (Time.time >= (lashDash + dashCoolDown))
{
// 尝试执行冲刺操作
AttemptToDash();
}
}
}
// 尝试进行冲刺的方法
private void AttemptToDash()
{
// 设置正在进行冲刺的标志为真
isDashing = true;
// 设置剩余冲刺时间为设定的冲刺持续时间
dashTimeLeft = dashTime;
// 更新上次冲刺时间
lashDash = Time.time;
// 从玩家残影池中获取一个残影对象并显示
PlayerAfterImagePool.Instance.GetFromPool();
// 记录当前位置作为上一个图像的X坐标位置
lastImageXpos = transform.position.x;
}
// 检查冲刺状态的方法
private void CheckDash()
{
// 如果正在进行冲刺
if (isDashing)
{
// 如果剩余冲刺时间大于0
if (dashTimeLeft > 0)
{
// 设置正在移动和翻转的标志为假
canMove = false;
canFlip = false;
// 设置刚体的速度,实现冲刺效果
rigidbody2D.velocity = new Vector2(dashSpeed * facingDirection, rigidbody2D.velocity.y);
// 减少剩余冲刺时间
dashTimeLeft -= Time.deltaTime;
// 如果玩家位置移动足够远,生成一个新的残影对象并显示
if (Mathf.Abs(transform.position.x - lastImageXpos) > distanceBetweenImages)
{
PlayerAfterImagePool.Instance.GetFromPool();
lastImageXpos = transform.position.x;
}
}
// 如果剩余冲刺时间小于等于0,或者玩家触碰到墙壁
if (dashTimeLeft <= 0 || isTouchingWall)
{
// 设置正在进行冲刺的标志为假
isDashing = false;
// 设置正在移动和翻转的标志为真
canMove = true;
canFlip = true;
}
}
}
}
PlayerAfterImageSprite.cs
在每次玩家冲刺时,将创建一个与玩家精灵相同的残影对象,并在一定时间后将其放回对象池。
public class PlayerAfterImageSprite : MonoBehaviour
{
[SerializeField]
private float activeTime = 0.1f;// 残影持续时间
private float timeActivated;// 激活时间记录
private float alpha;// 当前透明度
[SerializeField]
private float alphaSet = 0.8f;// 初始透明度
private float alphaMultiplier = 0.85f;// 透明度衰减系数
private Transform player;// 玩家的Transform组件
private SpriteRenderer spriteRenderer;// 当前对象的精灵渲染器组件
private SpriteRenderer playerSpriteRenderer;// 玩家的精灵渲染器组件
private Color color;// 当前颜色
// 在启用对象时调用的方法
private void OnEnable()
{
// 获取当前对象的精灵渲染器组件
spriteRenderer = GetComponent<SpriteRenderer>();
// 查找并获取标签为"Player"的游戏对象的Transform组件
player = GameObject.FindGameObjectWithTag("Player").transform;
// 获取玩家对象的精灵渲染器组件
playerSpriteRenderer = player.GetComponent<SpriteRenderer>();
// 设置初始透明度
alpha = alphaSet;
// 设置当前对象的精灵为与玩家相同的精灵
spriteRenderer.sprite = playerSpriteRenderer.sprite;
// 设置当前对象的位置与玩家位置相同
transform.position = player.position;
// 设置当前对象的旋转与玩家旋转相同
transform.rotation = player.rotation;
// 记录激活时间
timeActivated = Time.time;
}
// 在每一帧更新时调用的方法
private void Update()
{
// 根据透明度衰减系数更新透明度
alpha *= alphaMultiplier;
// 创建一个新的颜色,其中alpha值衰减
color = new Color(1f, 1f, 1f, alpha);
// 将新的颜色应用到当前对象的精灵渲染器
spriteRenderer.color = color;
// 如果当前时间超过了激活时间加上持续时间
if (Time.time >= (timeActivated + activeTime))
{
// 将当前对象添加回对象池
PlayerAfterImagePool.Instance.AddToPool(gameObject);
}
}
}
PlayerAfterImagePool.cs
对象池允许在需要时创建和回收残影对象,以提高性能和资源利用率。
public class PlayerAfterImagePool : MonoBehaviour
{
[SerializeField]
private GameObject afterImagePrefab;// 残影预制体
private Queue<GameObject> availableObjects = new Queue<GameObject>();// 可用的对象队列
// 单例模式的静态实例
public static PlayerAfterImagePool Instance { get; private set; }
// 在脚本实例被唤醒时调用的方法
private void Awake()
{
// 设置单例实例为当前脚本实例
Instance = this;
// 初始化对象池
GrowPool();
}
// 扩大对象池的方法
private void GrowPool()
{
// 循环创建一定数量的残影实例,并添加到对象池中
for (int i = 0; i < 10; i++)
{
// 实例化一个残影预制体
var instanceToAdd = Instantiate(afterImagePrefab);
// 将实例的父对象设置为当前对象池
instanceToAdd.transform.SetParent(transform);
// 将实例添加到对象池中
AddToPool(instanceToAdd);
}
}
// 将对象添加到对象池的方法
public void AddToPool(GameObject instance)
{
// 将实例设为非激活状态
instance.SetActive(false);
// 将实例加入可用对象队列
availableObjects.Enqueue(instance);
}
// 从对象池获取对象的方法
public GameObject GetFromPool()
{
// 如果可用对象队列为空,扩大对象池
if (availableObjects.Count == 0)
{
GrowPool();
}
// 从可用对象队列中取出一个实例
var instance = availableObjects.Dequeue();
// 将实例设为激活状态
instance.SetActive(true);
// 返回获取到的实例
return instance;
}
}
【3】unity操作
首先是创建两个游戏对象,一个将作为预制体的AfterImage
和存放这些预制体的PlayerAfterImagePool
,需要注意的是,它们都需要清除Z轴的影响,另外不要多此一举去给残影添加名为Player
的Tag
,不然后续的打包后的游戏存在问题。
然后如下设置AfterImage
,具体 有什么用,就看Unity中SortingLayer、Order in Layer和RenderQueue的讲解
之后的就把AfterImage
拖拽到Asset
文件夹里面,就能变成预制体了。
接着我们可以在PlayerAfterImagePool
复用这个预制体。
不要忘记给Player
游戏对象添加上Tag
接着就是到ProjectSettings
里添加一个冲刺的操作按钮
按钮就如下图这样设置。
【4】拓展
①打包成游戏后,该如何显示游戏的Bug?
第2章:基本战斗
不要拘泥于代码,因为后续都会被删掉,
part9:战斗连击
【1】C#脚本
PlayerController.cs
添加了可以公开玩家能不能翻转的属性事件,这将会作用于后面的动画中。
public void DisableFlip()
{
canFlip = false;
}
public void EnableFlip()
{
canFlip = true;
}
PlayerCombatController.cs
- 输入检测: 在
Update
函数中,通过CheckCombatInput
函数检测用户的输入,如果点击鼠标左键,战斗功能启用,就会标记为获取到输入,并记录输入的时间。 - 攻击触发: 在
CheckAttacks
函数中,如果获取到了输入且当前不在攻击状态,则将攻击状态设置为true
,并根据攻击状态切换动画参数。 - 攻击命中框检测: 在
CheckAttackHitBox
函数中,通过Physics2D.OverlapCircleAll
函数检测攻击命中框范围内的所有碰撞器(后续会为小怪添加)。 - 伤害应用: 在
CheckAttackHitBox
函数中,对于每个检测到的碰撞器,通过调用其父对象的SendMessage
函数发送名为"Damage"的消息,同时传递了攻击伤害值。注意这里需要保证小怪有Damage函数,才能实现,不然会抱错。 - 攻击动画结束: 在
FinishAttack1
函数中,当攻击动画完成后,将攻击状态设置为false
,并恢复动画参数,以便继续下一次攻击。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerCombatController : MonoBehaviour
{
[SerializeField]
private bool combatEnabled = true; // 是否启用战斗控制
[SerializeField]
private float inputTimer = 0.2f, attack1Radius = 0.8f, attack1Damage = 10f; // 输入计时器、攻击半径、攻击伤害
[SerializeField]
private Transform attack1HitBoxPos; // 攻击命中框的位置
[SerializeField]
private LayerMask whatIsDamageable; // 可造成伤害的层级掩码
private bool gotInput, isAttacking, isFirstAttack; // 是否获取到输入、是否正在攻击、是否是第一次攻击
private float lastInputTime = Mathf.NegativeInfinity; // 上次输入的时间
private Animator animator; // 动画控制器
private void Start()
{
animator = GetComponent<Animator>(); // 获取角色动画控制器组件
animator.SetBool("canAttack", combatEnabled); // 设置动画参数"canAttack"为combatEnabled的值
}
private void Update()
{
CheckCombatInput(); // 检查战斗输入
CheckAttacks(); // 检查攻击
}
private void CheckCombatInput()
{
if (Input.GetMouseButtonDown(0)) // 当鼠标左键点击时
{
if (combatEnabled) // 如果允许战斗
{
gotInput = true; // 标记为获取到输入
lastInputTime = Time.time; // 记录输入的时间
}
}
}
private void CheckAttacks()
{
if (gotInput)
{
if (!isAttacking)
{
gotInput = false; // 重置输入标记
isAttacking = true; // 标记为正在攻击
isFirstAttack = !isFirstAttack; // 切换攻击状态
animator.SetBool("attack1", true); // 设置动画参数"attack1"为true,触发攻击动画
animator.SetBool("firstAttack", isFirstAttack); // 设置动画参数"firstAttack"为isFirstAttack的值
animator.SetBool("isAttacking", isAttacking); // 设置动画参数"isAttacking"为true
}
}
if (Time.time >= lastInputTime + inputTimer) // 如果距离上次输入的时间超过了输入计时器
{
gotInput = false; // 重置输入标记
}
}
private void CheckAttackHitBox()
{
Collider2D[] detectedObjects = Physics2D.OverlapCircleAll(attack1HitBoxPos.position, attack1Radius, whatIsDamageable); // 检测攻击命中框范围内的所有碰撞器
foreach (Collider2D collider2D in detectedObjects)
{
collider2D.transform.parent.SendMessage("Damage", attack1Damage); // 向碰撞器的父对象发送"Damage"消息,对其他存在Damge函数的游戏对象造成攻击伤害
}
}
private void FinishAttack1()
{
isAttacking = false; // 结束攻击状态
animator.SetBool("isAttacking", isAttacking); // 设置动画参数"isAttacking"为false
animator.SetBool("attack1", false); // 设置动画参数"attack1"为false,结束攻击动画
}
private void OnDrawGizmos()
{
Gizmos.DrawWireSphere(attack1HitBoxPos.position, attack1Radius); // 在攻击命中框的位置绘制一个表示攻击范围的球体框架
}
}
【2】连击动画
导入素材,分别是第一次攻击和第二次攻击动画,还有后面要实验的稻草人。
老样子,按照之前那样设置精灵图,就不再赘述了。先暂时不管稻草人先,自行到Player
创建对应的两个攻击动画状态Attack1_1
和Attack1_2
,
然后我们的一个动画状态机则这样设置。
接着完成最后一步,绘制攻击的命中框,自行根据动画的表现,调整它的一个出现位置,如果觉得不麻烦,还可以自己允许关键帧操作,自己修改Player
碰撞体的一个变化,下图则是放入Player
游戏对象的组件设置。
自己看需要,对动画关键帧处添加事件,什么情况是不能转方向的?攻击要标记,要有攻击完成等等。
part10:怎么实现稻草人的生命周期动画
【1】C#脚本
CombatDummyController.cs
ParticleController.cs
【2】unity操作
重新修订图层和级数,新增敌人标签,结果如下:
首先是对Player
游戏对象修改Layer
,要避免子对象都成为了Player
,毕竟它们只是用来当射线检测我们的场景的。
然后不要忘记设置那些是他可以破坏的对象。
到projectsetting
上这样设置,根据文档Physics 2D - Unity 手册的内容,可知我们就是选择不让物体被破坏时或者已经死亡的情况下,与其他层级因为游戏对象的碰撞体发生交互。
【3】稻草人
生成稻草人精灵图的操作就按照之前那样操作即可,创建稻草人这个游戏对象,通过如下方式设计它的一个生命周期。
CombatDummy
的组件
Alive
的组件,另外它的Animation
的Idle
状态,受击往左移动和受击往右移动都自行使用unity
的Animation
实现,这里就不啰嗦了。
Broken Top
的组件
Broken Bottom
的组件
最后我们稻草人的一个动画状态机如下图这样设置。
【4】预制体
制作打击特效也是一样的方法步骤,只是要变成预制体存放到专门的文件夹,之后要记得删除游戏对象,另外它的动画是需要在最后没有帧播放时,添加事件就选择C#脚本中的FinishAnim()
,这样可以避免每次生成打击特效后没有及时清除。
part11+part12:死亡特效
自行按照视频的教程进行操作完成特效预制体的设置,懒得写具体的数值了。
小怪的设置应该是这样的。
专门分开代码逻辑层与动画表现层
- Enemy1(代码逻辑层)
-- Alive(负责动画表现、物理特性等属性)
第3章~第5章:改进有限状态机与简化动画状态机
不知道写什么内容好,结合我给的百度网盘链接看视频操作unity吧,我已经贴心地按章打包Asset那块了。
【1】理清思路
编写Enemy AI state machine
状态切换图,不要担心自己写的动画状态图过于复杂,在实际的动画状态机的一个判断与转换,都是通过严谨代码逻辑实现了简化,而只要明白继承、接口的含义,就可以知道有限状态机该怎么有效分层。
制作一个新状态的流程:Xxx
是自定义命名的意思,注意命名规范
我感觉这很像MVC设计模式。
- 创建一个继承状态的特定状态类:
XxxState:State
,负责作为表现层与逻辑层之间的一个桥梁,比如对应的代码逻辑有着对应的动画表现。 - 创建状态数据类
D_XxxState
,专门负责修改数值 - 创建敌人特定状态
E1_XxxState
,专门 - 对敌人类声明状态
- 设置动画机
VS实现父子继承操作的快捷键
比如下面的移动状态继承状态这个父类,
重写函数也是
FSM怎么方便操作的
通过以上的步骤,我们就可以简化小怪的操作,更专注于动画表现与代码逻辑之间的关系。
第5章后续跟如下的不同之处,在于通过接口的方式进一步减低代码的耦合度。
可以自行设置数值
还有Input System怎么用,也自行看视频。