【Unity】2D平台游戏初中级教程-笔记补充

文章目录

观前提醒

由于代码真的是太多,又被作者重构多次,各位可以根据下面的链接和文章有需自取。

链接地址

油管主(作者 ):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、角色、玩家都指同一个游戏对象

【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.pngTile Set.png按图操作,然后保存到Assets/Tile Map/Assets中,这个文件夹里面就只会保存切割好的精灵图了。

【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 Collider2DComposite 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】角色的移动和跳跃功能(基本原理)

实现移动:

  1. 在每帧更新中,获取水平输入值,通常使用Input.GetAxisRaw("Horizontal")
  2. 根据输入值和移动速度,计算出水平移动的速度。这是你想要将角色移动的方向和速度。
  3. 使用 Rigidbody2D 组件,将计算出的水平速度分配给角色的刚体,这将使角色沿水平方向移动。

示例代码片段:

csharp 复制代码
float movementInputDirection = Input.GetAxisRaw("Horizontal");
Vector2 movement = new Vector2(movementInputDirection * movementSpeed, rigidbody2D.velocity.y);
rigidbody2D.velocity = movement;

实现跳跃:

  1. 检查玩家是否按下跳跃键(例如,空格键)。
  2. 如果玩家按下了跳跃键,为角色的垂直速度(通常是 Y 轴速度)添加一个跳跃力(jumpForce)。
  3. 这将使角色向上跳跃。

示例代码片段:

csharp 复制代码
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 BrainUpdate 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版本的插件,如果是在公司用,应该明天就要滚了吧,个人就无所谓,毕竟可以重新下嘛。

Part3:动画,地面检测与跳跃次数

【1】Player的Idle状态的动画

在part2的视频中,我们要实现PlayerIdle动画,先打开并创建动画与动画控制器的标签页

接着我们在文件夹中,新建对应的动画控制器

点击我们的游戏对象,然后到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 Rate12,而Walk状态为30,动画效果会更好,当然做到下图的效果还需要后续的努力。

我们继续对Player游戏对象进行操作,完成跳跃的动画状态,方便后续的混合树。

【2】动画状态切换与条件判断

首先我们在Animator标签页中创建三个参数用于后续的条件判断,其中当Player跳跃时yVelocity为正,下落为负

在进行动画状态切换,拉箭头(make transition)之前,我们先要了解官方文档动画过渡 - Unity 手册 (unity3d.com)的内容,知道后续的一个基本操作。

参考文档混合树 - Unity 手册 (unity3d.com)的内容,__混合树__允许通过不同程度合并多个动画来使动画平滑混合,而它就很适合因为yVelocity的一个变化,展示跳跃动画状态从起跳到落地,所以我们需要在Animator标签页创建一个混合树,然后如下图设置即可。

最终的一个动画状态机如下图所示:

【3】C# 核心代码,地面检测与修复无限跳跃

PlayerController.cs

代码中//...表示承接上文的代码,如果出现了函数则表示替换/添加。

csharp 复制代码
// 玩家控制器类,继承自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.xmovementSpeedmovementInputDirection决定,而我DEBUG 后发现方向值是固定为0右为1,左为-1 )的,因此如果你也出现了这样的BUG ,那么就跟我一样,修改CheckMovementDirection()函数中isWalking的条件判断即可。

csharp 复制代码
if (movementInputDirection != 0) isWalking = true;
else isWalking = false;

而我目前也暂时没有从官方文档2D 刚体 - Unity 手册 (unity3d.com)中得到任何启示。

或者修改成如下的写法:

csharp 复制代码
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

csharp 复制代码
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的脚本,它与上面本章的part4Applymovement()中在对移动判断的区别:

函数 本章 视频
结构与可读性 代码简洁,易读 条件语句较多,可读性稍差
性能 较少使用物理引擎操作,性能可能稍好 使用了AddForce,可能对性能有微小影响
维护性 简单逻辑,易于维护 多条件语句,需要更多注意维护
扩展性 功能较简单,扩展性有限 使用AddForce,具备更大的功能扩展性

选择哪个函数更好取决于你的需求和项目规模。如果项目相对简单,强调代码的可读性和维护性,本章可能更适合。如果你希望拥有更多的物理特性和灵活性,视频的可能更合适。

csharp 复制代码
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(),考虑计时器对跳跃的影响(实际的跳墙操作我并不觉得好

csharp 复制代码
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】原理

平台攀爬的基本原理如下

  1. 检测台阶探测点: 通过一个射线起点 ledgeCheck 来探测角色是否接触到台阶。当角色碰到墙壁,但没有碰到台阶时,记录下台阶底部的位置 ledgePosBot
  2. 检测能否攀爬: 通过之前的墙面检测射线,当角色贴着墙壁,就会显示为true。如果之前没有检测到台阶并且此时发现了台阶(!ledgeDetected),则将 ledgeDetected 标志设置为 true,并记录台阶底部的位置。
  3. 开始攀爬: 一旦检测到台阶且没有进行过攀爬操作(!canClimbLedge),则将 canClimbLedge 设置为 true,表示角色可以执行攀爬动作。根据角色的朝向,计算出两个可能的台阶顶部位置 ledgePos1ledgePos2
  4. 禁止移动和翻转: 在进行攀爬时,设置 canMovecanFlipfalse,以防止角色在攀爬过程中移动或翻转。
  5. 更新攀爬的动画: 设置角色的动画状态,表示角色正在进行攀爬操作。
  6. 移动到台阶顶部位置: 将角色的位置移动到台阶顶部位置 ledgePos1,以便角色完成攀爬。
  7. 攀爬完成: 当攀爬结束时,例如通过动画事件触发,将 canClimbLedge 设置为 false,允许角色恢复移动和翻转,然后将角色的位置移动到另一个台阶顶部位置 ledgePos2,以确保角色不再粘在台阶上。

【2】C#脚本

PlayerController.cs

csharp 复制代码
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.FloorMathf.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】原理

玩家的冲刺与残影效果的实现原理如下:

  1. 玩家冲刺效果实现

    • 当玩家按下冲刺按钮时,检查冲刺冷却时间是否已过,并在条件满足时触发冲刺操作。
    • 在冲刺操作中,玩家的速度被设置为一个较高的值,使其在短时间内快速移动。
    • 冲刺过程中,更新玩家位置,同时生成玩家的残影效果,以模拟高速移动的轨迹。
  2. 残影效果实现

    • 创建一个对象池用于管理残影对象,初始时创建一定数量的残影对象。
    • 在玩家冲刺时,从对象池中获取一个残影对象,并将其位置与玩家位置相同,显示在玩家后方。
    • 残影对象的透明度逐渐减小,使得残影逐渐消失,营造出残影效果。
    • 超过一定时间后,将残影对象放回对象池中以供下次使用。
  3. 对象池的作用

    • 对象池是一个预先创建并维护的对象集合,用于减少动态创建和销毁对象的开销。
    • 在需要时,从对象池中获取闲置的对象,避免了频繁的内存分配和回收操作。
    • 对象池能够提高性能,降低资源消耗,并且更加适用于需要频繁创建和销毁的对象,如残影效果

【2】C#脚本

PlayerController.cs

csharp 复制代码
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

在每次玩家冲刺时,将创建一个与玩家精灵相同的残影对象,并在一定时间后将其放回对象池。

csharp 复制代码
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

对象池允许在需要时创建和回收残影对象,以提高性能和资源利用率。

csharp 复制代码
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轴的影响,另外不要多此一举去给残影添加名为PlayerTag,不然后续的打包后的游戏存在问题。

然后如下设置AfterImage,具体 有什么用,就看Unity中SortingLayer、Order in Layer和RenderQueue的讲解

之后的就把AfterImage拖拽到Asset文件夹里面,就能变成预制体了。

接着我们可以在PlayerAfterImagePool复用这个预制体。

不要忘记给Player游戏对象添加上Tag

接着就是到ProjectSettings里添加一个冲刺的操作按钮

按钮就如下图这样设置。

【4】拓展

①打包成游戏后,该如何显示游戏的Bug?

第2章:基本战斗

不要拘泥于代码,因为后续都会被删掉,

part9:战斗连击

【1】C#脚本

PlayerController.cs

添加了可以公开玩家能不能翻转的属性事件,这将会作用于后面的动画中。

csharp 复制代码
	public void DisableFlip()
    {
        canFlip = false;
    }

    public void EnableFlip()
    {
        canFlip = true;
    }

PlayerCombatController.cs

  1. 输入检测:Update函数中,通过CheckCombatInput函数检测用户的输入,如果点击鼠标左键,战斗功能启用,就会标记为获取到输入,并记录输入的时间。
  2. 攻击触发:CheckAttacks函数中,如果获取到了输入且当前不在攻击状态,则将攻击状态设置为true,并根据攻击状态切换动画参数。
  3. 攻击命中框检测:CheckAttackHitBox函数中,通过Physics2D.OverlapCircleAll函数检测攻击命中框范围内的所有碰撞器(后续会为小怪添加)。
  4. 伤害应用:CheckAttackHitBox函数中,对于每个检测到的碰撞器,通过调用其父对象的SendMessage函数发送名为"Damage"的消息,同时传递了攻击伤害值。注意这里需要保证小怪有Damage函数,才能实现,不然会抱错。
  5. 攻击动画结束:FinishAttack1函数中,当攻击动画完成后,将攻击状态设置为false,并恢复动画参数,以便继续下一次攻击。
csharp 复制代码
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_1Attack1_2

然后我们的一个动画状态机则这样设置。

接着完成最后一步,绘制攻击的命中框,自行根据动画的表现,调整它的一个出现位置,如果觉得不麻烦,还可以自己允许关键帧操作,自己修改Player碰撞体的一个变化,下图则是放入Player游戏对象的组件设置。

自己看需要,对动画关键帧处添加事件,什么情况是不能转方向的?攻击要标记,要有攻击完成等等。

part10:怎么实现稻草人的生命周期动画

【1】C#脚本

CombatDummyController.cs
ParticleController.cs

【2】unity操作

重新修订图层和级数,新增敌人标签,结果如下:

首先是对Player游戏对象修改Layer,要避免子对象都成为了Player,毕竟它们只是用来当射线检测我们的场景的。

然后不要忘记设置那些是他可以破坏的对象。

projectsetting上这样设置,根据文档Physics 2D - Unity 手册的内容,可知我们就是选择不让物体被破坏时或者已经死亡的情况下,与其他层级因为游戏对象的碰撞体发生交互。

【3】稻草人

生成稻草人精灵图的操作就按照之前那样操作即可,创建稻草人这个游戏对象,通过如下方式设计它的一个生命周期。

CombatDummy的组件

Alive的组件,另外它的AnimationIdle状态,受击往左移动和受击往右移动都自行使用unityAnimation实现,这里就不啰嗦了。

Broken Top的组件

Broken Bottom的组件

最后我们稻草人的一个动画状态机如下图这样设置。

【4】预制体

制作打击特效也是一样的方法步骤,只是要变成预制体存放到专门的文件夹,之后要记得删除游戏对象,另外它的动画是需要在最后没有帧播放时,添加事件就选择C#脚本中的FinishAnim(),这样可以避免每次生成打击特效后没有及时清除。

part11+part12:死亡特效

自行按照视频的教程进行操作完成特效预制体的设置,懒得写具体的数值了。

小怪的设置应该是这样的。

专门分开代码逻辑层与动画表现层

bash 复制代码
- Enemy1(代码逻辑层)
-- Alive(负责动画表现、物理特性等属性)

第3章~第5章:改进有限状态机与简化动画状态机

不知道写什么内容好,结合我给的百度网盘链接看视频操作unity吧,我已经贴心地按章打包Asset那块了。

【1】理清思路

编写Enemy AI state machine状态切换图,不要担心自己写的动画状态图过于复杂,在实际的动画状态机的一个判断与转换,都是通过严谨代码逻辑实现了简化,而只要明白继承、接口的含义,就可以知道有限状态机该怎么有效分层。

制作一个新状态的流程Xxx是自定义命名的意思,注意命名规范

我感觉这很像MVC设计模式。

  1. 创建一个继承状态的特定状态类:XxxState:State,负责作为表现层与逻辑层之间的一个桥梁,比如对应的代码逻辑有着对应的动画表现。
  2. 创建状态数据类 D_XxxState,专门负责修改数值
  3. 创建敌人特定状态 E1_XxxState,专门
  4. 对敌人类声明状态
  5. 设置动画机

VS实现父子继承操作的快捷键

比如下面的移动状态继承状态这个父类,

重写函数也是

FSM怎么方便操作的

通过以上的步骤,我们就可以简化小怪的操作,更专注于动画表现与代码逻辑之间的关系。

第5章后续跟如下的不同之处,在于通过接口的方式进一步减低代码的耦合度。

可以自行设置数值

还有Input System怎么用,也自行看视频。

相关推荐
speop5 分钟前
llm的一点学习笔记
笔记·学习
运维小雅15 分钟前
哪些因素会直观地影响到产品销量?
经验分享·笔记·媒体
抓饼先生39 分钟前
Linux control group笔记
linux·笔记·bash
ue星空43 分钟前
月2期学习笔记
学习·游戏·ue5
搞一搞汽车电子1 小时前
S32K3平台eMIOS 应用说明
开发语言·驱动开发·笔记·单片机·嵌入式硬件·汽车
wanhengidc1 小时前
云手机运行流畅,秒开不卡顿
运维·网络·科技·游戏·智能手机
大筒木老辈子2 小时前
Linux笔记---封装套接字
笔记
AlexMercer10122 小时前
[前端]1.html基础
前端·笔记·学习·html
楚肽生物小敏2 小时前
Cy3-Tyramide,Cyanine 3 Tyramide; 174961-75-2
笔记
songx_994 小时前
leetcode9(跳跃游戏)
数据结构·算法·游戏