【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怎么用,也自行看视频。

相关推荐
LuH11241 小时前
【论文阅读笔记】IC-Light
论文阅读·笔记
是小菜呀!1 小时前
实验四 触发器
笔记
悲伤小伞1 小时前
C++_数据结构_详解二叉搜索树
c语言·数据结构·c++·笔记·算法
灰太狼不爱写代码4 小时前
CUDA11.4版本的Pytorch下载
人工智能·pytorch·笔记·python·学习
Aileen_0v010 小时前
【AI驱动的数据结构:包装类的艺术与科学】
linux·数据结构·人工智能·笔记·网络协议·tcp/ip·whisper
Rinai_R12 小时前
计算机组成原理的学习笔记(7)-- 存储器·其二 容量扩展/多模块存储系统/外存/Cache/虚拟存储器
笔记·物联网·学习
吃着火锅x唱着歌12 小时前
PHP7内核剖析 学习笔记 第四章 内存管理(1)
android·笔记·学习
ragnwang12 小时前
C++ Eigen常见的高级用法 [学习笔记]
c++·笔记·学习
胡西风_foxww13 小时前
【es6复习笔记】rest参数(7)
前端·笔记·es6·参数·rest
胡西风_foxww16 小时前
【es6复习笔记】函数参数的默认值(6)
javascript·笔记·es6·参数·函数·默认值