Intro

本文是对教程 Learn How to Make a 2D Platformer in Unity 2022 - FULL GAMEDEV COURSE! 做的笔记,用来锻炼 Unity 的实践能力。

Project Setup

首先我们需要创建一个新的项目。因为这个项目中的所有美术元素都是像素风格的,所以我们需要创建一个预设文件用来导入纹理,这样,每次我们到入新的像素文件的时候,这些文件都可以被预处理为正确的格式。

在创建项目时,我们选择创建 2D (URP) 类型的项目,这类项目会使用 Universal Render Pipeline。

在项目创建好以后,我们开始创建预设文件。首先我们需要选择任意一张 JPEG 或 PNG 图片,把它拖放到项目中的 Assets 文件夹中。点击文件夹中的图片,就会在 Inspector 一栏显示它的信息,我们在这里进行如下改动:

  1. 把 Pixels Per Unit 设置为 16
  2. 修改 Filter Mode 修改为 Point (no filter)
  3. 修改 Max Size 为 4096
  4. 修改 Compression 为 None

之后点击右下角的 Apply 按钮。

设置完成后,点击 Inspector 一栏左上角的开关组形状的按钮,它位于 Open 按钮的上方,在弹出的窗口上点击 Create new Preset 按钮。我们在 Assets 文件夹下新创建一个 Presets 文件夹,然后把生成的文件放入其中。这样,预设文件就创建好了。

之后,我们需要把新创建的预设文件设置为默认的 TextureImporter。快速的方法是点击新生成的预设文件,并在 Inspector 一栏中点击按钮 Add to TextureImporter default。我们也可以在 Edit 菜单下找到的 Project Settings 窗口中进行更详细的设置。在 Project Settings 窗口中的 Preset Manager 中点击 Add Default Preset 按钮,在弹出窗口中搜索并选择 TextureImporter。然后在新创建的预设的右侧区域 Preset 区域中通过按钮选择刚刚创建的预设文件,这就完成了设置操作。在这里,预设项目的左侧区域是 Filter 区域,如果我们想对不同类型的文件进行不同类型的预设,比如说对 JPEG 和 PNG 类型的图片进行不同的预处理,就可以在 Filter 区域进行设置。

Installing Art Packs

前往以下地址下载对应的文件:

  1. Animated Pixel Adventurer

    Adventurer-1.5.zip

    Adventurer-Hand-Combat.zip

    Adventurer-Bow.zip

  2. Fantasy Knight - Free Pixelart Animated Character

    FreeKnight_v1.zip

  3. M5X7

    m5x7.ttf

  4. Free Pixelart Tileset - Cute Forest

    FreeCuteTilesetv1.zip

  5. Pixel food

    FreePixelFood.zip

  6. Kyrise’s Free 16x16 RPG Icon Pack

    Kyrises_16x16_RPG_Icon_Pack_V1.3.zip

  7. Monsters Creatures Fantasy

    Monsters_Creatures_Fantasy.zip

    Monster_Creatures_Fantasy(Version 1.2).zip

    Monster_Creatures_Fantasy(Version 1.3).zip

  8. Legendary JRPG Battle Music Pack FREE

    Legendary_JRPG_Battle_Music_Pack.zip

  9. RPG Essentials SFX - Free!

    RPG_Essentials_Free.zip

接下来,打开 Unity 项目的 Assets 文件夹,创建一个新的文件夹 Art,然后依次按照如下方法导入刚刚下载的文件:

  1. 创建 Adventurer 文件夹并导入 Adventurer-1.5.zip,Adventurer-Hand-Combat.zip 和 Adventurer-Bow.zip 文件中的内容,把其中子文件夹 Individual Sprites 中的内容合并到一起。
  2. 创建 FreeKnight 文件夹并导入 FreeKnight_v1.zip 中的内容。
  3. 创建 FreeCuteTileset 文件夹并导入 FreeCuteTilesetv1.zip 中的内容。
  4. 创建 PixelFood 文件夹并导入 FreePixelFood.zip 中 Assets 文件夹中的 Sprite 文件夹和 Sprite.meta 文件。
  5. 创建 RPGIconPack 文件夹并导入 Kyrises_16x16_RPG_Icon_Pack_V1.3.zip 中的内容。
  6. 创建 MonstersCreaturesFantasy 文件夹并导入 Monsters_Creatures_Fantasy.zip,Monster_Creatures_Fantasy(Version 1.2).zip 和 Monster_Creatures_Fantasy(Version 1.3).zip文件夹中的内容,把重名文件夹中的内容合并到一起。

之后返回 Assets 文件夹,创建一个新的文件夹 UI,在 UI 中创建新的文件夹 Fonts,把下载的 m5x7.ttf 文件直接拖进去。

再返回 Assets 文件夹,创建一个新的文件夹 Audio,在 Audio 中创建新的文件夹 Music 和 SFX 分别用来存放背景音乐和特效音乐(Sound Effects)。先解压 Legendary_JRPG_Battle_Music_Pack.zip 文件,其中我们只用到 10 Battle1 (8bit style) 文件夹中的内容,把这个文件夹拖到 Music 文件夹中。之后把 RPG_Essentials_Free.zip 文件夹中的内容导入 SFX 文件夹中。

Unity Packages Setup

这一章节将在 Unity 编辑器中的插件商城下载两个包:Input System 和 Cinemachine,并关掉 Reload Domain 开关,从而允许我们能在编辑和游玩模式间快速切换。

在 Window 菜单下打开 Package Manager,在窗口左上角把“Package: In Project”切换到“Package: Unity Registry”,在右边的搜索框分别搜索要下载的两个包并点击下载。其中 Input System 在下载完成后会要求重启编辑器。下载完成后应该可以在“Package: In Project”找到它们。

在 Edit 菜单下打开 Project Setting,窗口左侧选择 Editor,下滑到底部,勾选 Enter Play Mode Options 选项和 Reload Scene 选项,选项的详细解释可以通过鼠标悬停进行查看。

Basic Background

这一节将开始搭建游戏背景。

首先前往 Assets 文件夹中的 Scenes 文件夹,把 SampleScene 改名为 GameplayScene。之后进行以下操作:

  1. 添加空物体 Background
  2. 把 Background 的位置设置到原点
  3. 在 Background 内添加空物体 BG1
  4. 为 BG1 添加 Sprite Renderer 组件
  5. 前往 Art 文件夹中的 FreeCuteTileset 文件夹
  6. 把物体 BG1 的渲染器组件中的 Sprite 设置为图片 BG1
  7. 把渲染器组件中的 Draw Mode 切换为 Tiled
  8. 把 Width 设置为 500
  9. 在 Sorting Layer 的选项中选择 Add Sorting Layer
  10. 添加 Layer 并命名为 Background
  11. 通过拖拽把 Background Layer 放到顶部(让它成为 Layer 0)
  12. 通过快捷键 Control + D 复制两份 BG1,并分别命名为 BG2 和 BG3
  13. 分别把物体 BG2 和 BG3 的渲染器组件中的 Sprite 设置为图片 BG2 和 BG3

以上操作涉及到的知识点有:

  1. 父物体与子物体
  2. 渲染器组件
  3. 绘制模式——平铺
  4. 图层排序
  5. 复制操作快捷键 Control + D

Player Walk

这一节我们将创建一个基本的玩家物体,并让它能够左右移动。

创建玩家

首先创建玩家物体:

  1. 在 GameplayScene 中创建一个新的空物体 Player
  2. 添加渲染器组件并设置图片为 adventurer-idle-00
  3. 添加 2D 刚体组件并勾选 Freeze Rotation 防止图片在特殊情况下发生旋转

预设体

我们可以把创建好的物体存储为预设体(Prefabs)方便后续重复使用:

  1. 在 Assets 文件夹中新建文件夹 Characters 并进入
  2. 新建 Enemies 文件夹和 Player 文件夹
  3. 将 Hierarchy 中的 Player 物体拖放到 Player 文件夹中,自动生成预设体

接收玩家输入

我们需要一个组件来接收玩家的输入:

  1. 在预设体 Player 中添加 Player Input 组件,点击 Create Actions 并在 Player 文件夹中创建 PlayerInputActions 文件,打开生成的文件(创建后会自动打开)可以查看动作的信息。比如在 Action Maps 中选择 Player,在 Action 中选中 Move,就可以在 Action Properties 查看到 Player 的 Move 动作会有一个类型为 Vector2 的参数,这个参数我们之后会用到
  2. 在PlayerInputActions 文件中的 Action Maps 中选择 Player,把 Actions 中的 Fire 修改为 Attack
  3. 将 Player Input 组件中的 Behavior 切换为 Invoke Unity Events

Player Input 组件的 Behavior 属性用于指定当接收到输入时如何通知其他脚本或组件,它的四种模式的解释如下:

  1. Send Messages:Player Input 组件将使用 Unity 的消息系统来通知其它脚本。我们需要在其它脚本中实现特定的接收函数(例如:OnMove),然后将其与 Player Input 组件的事件关联。当接收到输入时,Player Input 组件将调用关联的接收函数,并通过消息参数将输入信息传递给其他脚本。
  2. Broadcast Messages:与 Send Messages 类似,但它将向场景中所有具有匹配接收函数的物体发送消息。
  3. Invoke Unity Events:Player Input 组件将触发与之关联的 UnityEvent。
  4. Invoke C Sharp Events:Player Input 组件将触发与之关联的 C# 事件(C# event)。类似于 UnityEvent,我们可以在 Unity 编辑器中直接将目标函数绑定到 Player Input 组件的 C# 事件字段上。这种方法更符合 C# 的委托模型,并且性能较好。

控制游戏角色

接下来我们要创建一个脚本来根据玩家的输入控制游戏角色:

  1. 在 Assets 文件夹中新建文件夹 Scripts 并进入
  2. 创建 C# 脚本 PlayerController 并与预设体 Player 关联
  3. 打开 PlayerController 并进行以下修改
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

// 要求在关联 Player Controller 组件之前必须要有 Rigidbody2D 组件
[RequireComponent(typeof(Rigidbody2D))]
public class PlayerController : MonoBehaviour
{
public float walkSpeed = 5f;
Vector2 moveInput;
public bool IsMoving {get; private set;}
Rigidbody2D rb;

private void Awake()
{
// 在物体 Player 被创建后就获取它的刚体组件
rb = GetComponent<Rigidbody2D>();
}

// Start is called before the first frame update
void Start()
{

}

// Update is called once per frame
void Update()
{

}

private void FixedUpdate()
{
// 通过修改刚体组件的速度来让物体移动,刚体组件已处理与 delta time 相关的问题
rb.velocity = new Vector2(moveInput.x * walkSpeed, rb.velocity.y);
}

// 根据 Player Input 组件的特点,通过 context 获取输入的信息
public void OnMove(InputAction.CallbackContext context)
{
// 获取输入的动作信息中的 Vector2 类型的数据
moveInput = context.ReadValue<Vector2>();
// 判断物体是否发生移动
IsMoving = moveInput != Vector2.zero;
}
}

这里如果 VS Code 不能正确进行代码提示,可以参考这里。如果还是不行,尝试退出 VS Code 重新打开文件。

关联输入与控制

最后,我们需要关联 Player Input 组件和 Player Controller 组件

  1. 在 Player Input 组件中依次打开 Events 和 Player 扩展
  2. 在动作 Move 下点击加号按钮
  3. 在 Object 一栏中拖入 Player 物体或者 Player Controller 组件
  4. 在 Function 一栏中选择 Player Controller 组件中的 OnMove 函数

完成上述操作后,运行游戏就可以通过 AD 按键控制游戏角色左右移动了。(因为刚体组件,角色会下坠,可以暂时把刚体组件中的重力修改为 0)

上面的操作可以在预设体中进行,也可以直接对物体进行操作,然后利用组件右上角的拓展按钮中的选项应用到预设体中。

Cinemachine Camera

这一节我们将利用之前下载的 Cinemachine 来控制摄像机,让他能够跟随我们的游戏角色一起移动。

  1. 在 GameplayScene 下右键点击,选择 Cinemachine 中的 2D Camera
  2. 选择生成的 Vitrual Camera,在 Inspector 中进行以下修改
  3. 修改 Follow 为物体 Player,让摄像机跟随游戏角色移动
  4. 调整 Lookahead Time 为 0.5,让摄像机预测角色 0.5 秒后的位置
  5. 调整 Lookahead Smoothing 为 6,让摄像机在角色停止移动后更平滑地移到角色身上
  6. 调整 XYZ 轴的 Damping 值,默认为 1 即可,让摄像机的移动有稍微的延迟,更符合视觉习惯
  7. 根据需要调整 Dead Zone 的值,当角色处于 Dead Zone 时,摄像机不会移动

Parallax Effect

这一节将会对背景图片设置视差效果,从而在游戏角色移动的时候,不同的背景能够以不同的速度移动。

关于视差效果的详细信息可以在这里学习。

首先我们进入 Scripts 文件夹并创建新的脚本文件 ParallaxEffect,文件内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ParallaxEffect : MonoBehaviour
{
public Camera cam;
public Transform followTarget;

// Starting position for the parallax game object
Vector2 startingPosition;

// Start Z value of the parallax game object
// Z alue is the distance into the background
float startingZ;

// Distance that the camera has moved from the starting position of the parallax game object
// => means it updates on every frame
Vector2 camMoveSinceStart => (Vector2)cam.transform.position - startingPosition;

float zDistanceFromTarget => transform.position.z - followTarget.transform.position.z;

// If the parallax game object is in front of the player, use nearClipPlane, otherwise use farClipPlane
float clippingPlane => (cam.transform.position.z + (zDistanceFromTarget > 0 ? cam.farClipPlane : cam.nearClipPlane));

// The futher the object from the player, the faster the parallax game object will move
// Drag it's Z value closer to hte target to make it move slower
float parallaxFactor => Mathf.Abs(zDistanceFromTarget) / clippingPlane;

// Start is called before the first frame update
void Start()
{
// transform.position is Vector3, but the z-axis will be shaved off automatically
startingPosition = transform.position;
startingZ = transform.position.z;
}

// Update is called once per frame
void Update()
{
Vector2 newPosition = startingPosition + camMoveSinceStart * parallaxFactor;
transform.position = new Vector3(newPosition.x, newPosition.y, startingZ);
}
}

之后选择 BG1、BG2 和 BG3,为它们添加 ParallaxEffect 组件,并设置 Cam 和 Follow Target 分别为 Main Camera 和 Player 物体。再分别修改 BG1、BG2 和 BG3 的 Z 坐标为 -1,-0.8 和 -0.5(值适当即可)。

Animations

这一节将给游戏角色添加移动时的动画。

首先我们添加动画元素

  1. 在 Player 的预设体上新加一个组件 Animator

  2. 在 Player 文件夹下创建 Animator Controller 文件,命名为 AC_Player

  3. 把 Animator 组件的 Controller 属性设置为 AC_Player

  4. 在 Window 菜单中的 Animation 选项中打开 Animation 和 Animator 窗口,并拖放到合适的位置

  5. 在 Animation 窗口中点击 Create 按钮创建一个 Animation Clip,保存在 Player 文件夹中,命名为 player_idle

  6. 在 Animation 窗口把 Sample 的值设置为 10,或者通过鼠标滑轮调整时间条的单位为 0:10

  7. 在 Art 文件夹下找到 Adventurer 文件夹中的 idle 对应的图片,可以根据喜好选用版本一或版本二

  8. 选中 00,01,02 和 03 四张图片拖放到时间条上,让它们在时间条上的位置分别是 0:00,0:10,0:20 和 0:30

  9. 在 Animation 窗口左上角下拉 player_idle 可以创建新的 Animation Clip,重复 5 - 8,分别创建 player_walk 和 player_run 的动画

  10. 然后进入 Animator 窗口,右键创建一个子状态机,命名为 GroundStates

  11. 把三个动画放入子状态机

  12. 在 Base Layer 中右键点击 Entry 设置状态机的默认状态为 GroundStates,并选择其中的 player_idle

  13. 进入子状态机中,添加 Parameters 并根据下面的图示创建动画的转换关系

    GroundStates 子状态机

  14. 点击转换箭头(Transition),在 Inspector 中设置转换条件,比如在 isMoving 和 isRunning 都为 true 的情况下可以从 idle 状态转换为 run 状态,在 isRunning 变为 false 的时候又可以从 run 状态转换为 walk 状态,在 isMoving 变成 false 的时候可以从 run 状态或 walk 状态变为 idle 状态

  15. 在每个 Transition 的 Inspector 中的 Setting 下,把 Has Exit Time 选项的勾选取消,把 Transition Duration 设置为 0

之后我们更新 PlayerController 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D))]
public class PlayerController : MonoBehaviour
{
public float walkSpeed = 5f;
public float runSpeed = 8f;
Vector2 moveInput;

public float CurrentMoveSpeed
{
get
{
if (IsMoving)
{
if (IsRunning)
{
return runSpeed;
}
else
{
return walkSpeed;
}
}
else
{
return 0;
}
}
}

[SerializeField]
private bool _isMoving = false;
[SerializeField]
private bool _isRunning = false;

public bool IsMoving
{
get
{
return _isMoving;
}
private set
{
_isMoving = value;
animator.SetBool("isMoving", value);
}
}

public bool IsRunning
{
get
{
return _isRunning;
}
private set
{
_isRunning = value;
animator.SetBool("isRunning", value);
}
}

private bool _isFacingRight = true;
public bool IsFacingRight
{
get
{
return _isFacingRight;
}
private set
{
if (_isFacingRight != value)
{
// Flip the local scale to make the player face the opposite direction
transform.localScale *= new Vector2(-1, 1);
}
_isFacingRight = value;
}
}

Rigidbody2D rb;
Animator animator;

private void Awake()
{
rb = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
}

// Start is called before the first frame update
void Start()
{

}

// Update is called once per frame
void Update()
{

}

private void FixedUpdate()
{
rb.velocity = new Vector2(moveInput.x * CurrentMoveSpeed, rb.velocity.y);
}

public void OnMove(InputAction.CallbackContext context)
{
moveInput = context.ReadValue<Vector2>();
IsMoving = moveInput != Vector2.zero;
SetFacingDirection(moveInput);
}

private void SetFacingDirection(Vector2 moveInput)
{
if (moveInput.x > 0 && !IsFacingRight)
{
IsFacingRight = true;
}
else if (moveInput.x < 0 && IsFacingRight)
{
IsFacingRight = false;
}
}

public void OnRun(InputAction.CallbackContext context)
{
if (context.started)
{
IsRunning = true;
}
else if (context.canceled)
{
IsRunning = false;
}
}
}

最后,我们添加新的输入接收和角色控制

  1. 在 Player 预设体中的 Player Input 组件中双击 Player Input Actions 打开设置面板,或者通过 Player 文件夹下的 PlayerInputActions 打开

  2. 在 Player 中新建 Action,命名为 Run,在 Binding 中设置 Path 为 Left Shift 键(可以通过 Listen 按钮得到键盘输入)

  3. 参考关联输入与控制部分设置 Player Input 组件中的 Run 事件

完成上面的操作以后,游戏角色应该就可以根据输入左右移动的同时播放相应的动画,并可以通过 Shift 按键进行奔跑。

Animation String List

上一节中在控制角色动画的时候,我们创建了两个 Bool 变量,并在 PlayerInputActions 文件中通过两个 String 来控制这两个变量。当动画数量变多以后,我们可能会有很多个变量需要管理,因此更科学的方法是把所有的动画变量放到一个文件中去管理。

在 Scripts 文件夹下,创建新的 C# 脚本,并命名为 AnimationStrings,之后填写以下内容:

1
2
3
4
5
6
7
8
9
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

internal class AnimationStrings
{
internal static string isMoving = "isMoving";
internal static string isRunning = "isRunning";
}

然后回到 PlayerInputActions 文件中,吧对应的代码修改如下:

1
2
animator.SetBool(AnimationStrings.isMoving, value);
animator.SetBool(AnimationStrings.isRunning, value);

之后也将用同样的方式来管理动画变量字符串。

Ground Tileset

这一节将创建游戏角色可以站立的土地。

首先我们把美术元素中的 Tileset 文件切割开:

  1. 选中 FreeCuteTileset 文件夹中的 Tileset 文件
  2. 在 Inspector 窗口把 Sprite Mode 切换为Multiple
  3. 点击 Sprite Editor 按钮,点击 Apply
  4. 在 Slice 选项下,把 Type 切换为 Grid By Cell Size
  5. 把 Pixel Size 的 X 和 Y 值改为 16
  6. 点击 Slice 按钮和 Apply 按钮

然后就可以看到 Tileset 文件被切割成了 42 份。

接下来我们根据切割得到的文件创建调色板:

  1. 在 Art 文件夹下创建 Palettes 文件夹
  2. 在 Window 菜单的 2D 选项中打开 Tile Palette 窗口,拖放到合适位置
  3. 点击 Create New Palette 并命名为 ForestTileset,保存到 Palettes 文件夹中

接下来要往调色板上添加瓷砖:

  1. 在 Palettes 文件夹下创建 Tiles 文件夹,再在 Tiles 文件夹下创建 ForestTiles 文件夹
  2. 拖选 FreeCuteTileset 文件夹中的 Tileset 文件,放到 Tile Palette 窗口中,把生成的文件保存到 ForestTiles 文件夹

之后我们就可以创建地面了:

  1. 在 Hierarchy 窗口右键选择 2D Object 中的 Tilemap 中的 Rectangular
  2. 把生成的地形对象命名为 Ground
  3. 在渲染器组件的 Sorting Layer 选项下添加新的图层,命名为 Ground 并放在 Default 和 Background 之间,并将其设置为该地形文件的图层
  4. 添加 Tilemap Collider 2D 组件,该组件会自动添加刚体组件
  5. 把刚体组件中的 Body Type 改为 Static,因为地面不会变化
  6. 添加 Composite Collider 2D 组件,并勾选 Tilemap Collider 2D 组件中的 Used By Composite 选项

有时候我们需要设置可以穿越的地形,可以按照上面的方法新建一个地形对象,仍然要设置它的图层为 Ground,但 Order in Layer 为 -1,并且不添加碰撞组件。

然而此时游戏角色还是会穿过地面往下掉(把重力改回 1),因此需要给游戏角色添加碰撞检测:

  1. 给 Player 的预设体添加 Capsule Collider 2D 组件
  2. 点击 Edit Collider 对应的按钮编辑碰撞体积到合适大小

最后我们可能需要改变背景的大小,以防角色移动到某一个地方的时候背景会消失,只需把 Background 物体的 Scale 中的值适当调大即可。

Ground & Air States

这一节我们将为游戏角色添加跳跃动作,并通过与地面、墙面的距离检测让游戏角色不会被卡在墙上的同时,能够实现跳跃动画之间的切换。

添加一个新的 C# 脚本,命名为 TouchingDirections,用于检测游戏物体是否与地面、墙面和天花板发生接触,并设置对应的动画参数。脚本代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// The following script is used to check if a game object is grounded by casting a 2D ray downwards using a CapsuleCollider2D component.
// It also updates an Animator's bool parameter "isGrounded" based on the grounded status.
public class TouchingDirections : MonoBehaviour
{
// A ContactFilter2D used to determine which colliders should be considered when casting the ray.
public ContactFilter2D castFilter;
// The distance of the ray casted from the object's CapsuleCollider2D.
public float groundDistance = 0.05f;
public float wallDistance = 0.2f;
public float ceilingDistance = 0.05f;

CapsuleCollider2D touchingCollider;
Animator animator;

// Array to store RaycastHit2D results when casting the ray.
RaycastHit2D[] groundHits = new RaycastHit2D[5];
RaycastHit2D[] wallHits = new RaycastHit2D[5];
RaycastHit2D[] ceilingHits = new RaycastHit2D[5];

[SerializeField]
private bool _isGrounded;
public bool IsGrounded
{
get
{
return _isGrounded;
}
set
{
_isGrounded = value;
animator.SetBool(AnimationStrings.isGrounded, value);
}
}

[SerializeField]
private bool _isOnWall;
public bool IsOnWall
{
get
{
return _isOnWall;
}
set
{
_isOnWall = value;
animator.SetBool(AnimationStrings.isOnWall, value);
}
}

[SerializeField]
private bool _isOnCeiling;
public bool IsOnCeiling
{
get
{
return _isOnCeiling;
}
set
{
_isOnCeiling = value;
animator.SetBool(AnimationStrings.isOnCeiling, value);
}
}

private Vector2 wallCheckDirection => gameObject.transform.localScale.x > 0 ? Vector2.right : Vector2.left;

// Awake is called when the script instance is being loaded.
private void Awake()
{
touchingCollider = GetComponent<CapsuleCollider2D>();
animator = GetComponent<Animator>();
}

// FixedUpdate is called at fixed intervals and is commonly used for physics-related updates.
void FixedUpdate()
{
// Cast a 2D ray downwards from the CapsuleCollider2D.
// If the ray hits any colliders within the given groundDistance, consider the object as grounded.
IsGrounded = touchingCollider.Cast(Vector2.down, castFilter, groundHits, groundDistance) > 0;
IsOnWall = touchingCollider.Cast(wallCheckDirection, castFilter, wallHits, wallDistance) > 0;
IsOnCeiling = touchingCollider.Cast(Vector2.up, castFilter, ceilingHits, ceilingDistance) > 0;
}
}

参考添加新的输入接收和角色控制部分新增一个 Jump 动作,并更新 PlayerController 的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D), typeof(TouchingDirections))]
public class PlayerController : MonoBehaviour
{
public float walkSpeed = 5f;
public float runSpeed = 8f;
public float jumpImpulse = 10f;
public float airWalkSpeed = 3f;
Vector2 moveInput;
TouchingDirections touchingDirections;

public float CurrentMoveSpeed
{
get
{
if (IsMoving && !touchingDirections.IsOnWall)
{
if (touchingDirections.IsGrounded)
{
if (IsRunning)
{
return runSpeed;
}
else
{
return walkSpeed;
}
}
else
{
return airWalkSpeed;
}
}
else
{
return 0;
}
}
}

[SerializeField]
private bool _isMoving = false;
[SerializeField]
private bool _isRunning = false;

public bool IsMoving
{
get
{
return _isMoving;
}
private set
{
_isMoving = value;
animator.SetBool(AnimationStrings.isMoving, value);
}
}

public bool IsRunning
{
get
{
return _isRunning;
}
private set
{
_isRunning = value;
animator.SetBool(AnimationStrings.isRunning, value);
}
}

private bool _isFacingRight = true;
public bool IsFacingRight
{
get
{
return _isFacingRight;
}
private set
{
if (_isFacingRight != value)
{
// Flip the local scale to make the player face the opposite direction
transform.localScale *= new Vector2(-1, 1);
}
_isFacingRight = value;
}
}

Rigidbody2D rb;
Animator animator;

private void Awake()
{
rb = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
touchingDirections = GetComponent<TouchingDirections>();
}

private void FixedUpdate()
{
rb.velocity = new Vector2(moveInput.x * CurrentMoveSpeed, rb.velocity.y);

animator.SetFloat(AnimationStrings.yVelocity, rb.velocity.y);
}

public void OnMove(InputAction.CallbackContext context)
{
moveInput = context.ReadValue<Vector2>();
IsMoving = moveInput != Vector2.zero;
SetFacingDirection(moveInput);
}

private void SetFacingDirection(Vector2 moveInput)
{
if (moveInput.x > 0 && !IsFacingRight)
{
IsFacingRight = true;
}
else if (moveInput.x < 0 && IsFacingRight)
{
IsFacingRight = false;
}
}

public void OnRun(InputAction.CallbackContext context)
{
if (context.started)
{
IsRunning = true;
}
else if (context.canceled)
{
IsRunning = false;
}
}

public void OnJump(InputAction.CallbackContext context)
{
// TODO: Check if alive
if (context.started && touchingDirections.IsGrounded)
{
animator.SetTrigger(AnimationStrings.jump);
rb.velocity = new Vector2(rb.velocity.x, jumpImpulse);
}
}
}

并在 AnimationStrings 文件中添加以下代码:

1
2
3
4
5
internal static string isGrounded = "isGrounded";
internal static string isOnWall = "isOnWall";
internal static string isOnCeiling = "isOnCeiling";
internal static string yVelocity = "yVelocity";
internal static string jump = "jump";

之后按照以下步骤添加对应的动画元素:

  1. 在 Animator 窗口添加三个 Bool 变量 isGrounded,isOnWall 和 isOnCeiling

  2. 在 Animator 窗口添加 Float 变量 yVelocity

  3. 在 Animator 窗口添加 Trigger 变量 jump

  4. 在 Animator 窗口中添加新的子状态机 AirStates

  5. 在 Animation 窗口中为 Player 预设体新添三个 Clip,分别为 player_jump,player_rising 和 player_falling,都存放到 Player 文件夹下

  6. 分别为刚才创建的三个 Clip 添加逐帧动画,图片素材在 Individual Sprites 文件夹中可以找到,其中 player_rising 的动画只有一帧,对应的是 adventurer-jump-03 文件,具体方法可以参考添加动画元素

  7. 在 Player 文件夹中找到 player_jump 对应的文件,在 Inspector 窗口中取消勾选 Loop Time

  8. 把新创建的三个 Clip 放到新创建的子状态机 AirStates 中,并设置 player_falling 为默认状态

  9. 在 GroundStates 子状态机中,为站立、行走和跑步三个动作每个都添加两个 Transition 指向 Exit,两个 Transition 的触发条件分别是 isGrounded 为 false 和 jump 被触发

    退出 GroundStates

  10. 前往 Base Layer,为 GroundStates 添加两个指向 AirStates 的 Transition,一个最终指向 StateMachine,触发条件为 isGrounded 为 false,另一个最终指向 player_jump,触发条件是 jump 被触发

  11. 在 Base Layer 为 AirStates 添加最终指向 GroundStates 的 Transition,触发条件是 isGrounded 为 true

  12. 在 AirStates 中按照如下图所示方式添加 Transition,其中 player_rising 和 player_falling 的相互转化取决于 yVelocity 是否大于零,它们退出的条件是 isGrounded 为 true,player_jump 会在 Exit Time 到 1 时自动转换为 player_rising

    AirStates 子状态机

上面涉及的所有 Transition 的 Transition Duration 为 0,并且除了 player_jump 到 player_rising 的 Transition,都没有 Exit Time(取消勾选 Has Exit Time)。

Player Attack Animation

这一节将为 Player 添加攻击动作,具体的内容与前面添加跳跃动作类似。

我们的操作有:

  1. 添加 GroundAttack 子状态机
  2. 添加 player_attack_1 的动画 Clip,注意取消勾选 Loop Time 选项
  3. 在 Animator 面板的设置如下
    1. 添加 attack 和 canMove 两个动画变量,并设置 canMove 默认状态下为 True
    2. GroundStates 在 attack 被触发的时候转入 GroundAttack,GroundAttack 在结束后自动返回 GroundStates
    3. 在 GroundAttack 中设置 player_attack_1 为默认状态,在其退出子状态机的 Transition 上勾选 Has Exit Time,设置 Exit Time 为 1,Transition Duration 为 0
    4. 在 GroundStates 中为站立、行走和跑步三个动作添加新的退出 Transition,触发条件都是 attack,取消勾选 Has Exit Time,设置 Transition Duration 为 0
  4. 在 Scripts 文件夹中添加了 StateMachine 文件夹,并在其中添加了 SetBoolBehaviour 脚本文件
  5. 为 GroundAttack 子状态机添加 SetBoolBehaviour 组件,设置 Bool Name 为 canMove,勾选 Update On State Machine 和 Value On Exit
  6. 更新了 PlayerController 文件和 AnimationStrings 文件
  7. Attack 动作我们在之前在 PlayerInputActions 文件中添加过,这里我把触发按键从鼠标左键修改为键盘 J 键
  8. 在 Player 的 Player Input 中关联 Attack 的输入与控制

在 AnimationStrings 中新增或修改的内容如下:

1
2
3
internal static string jumpTrigger = "jump";
internal static string attackTrigger = "attack";
internal static string canMove = "canMove";

新增的 SetBoolBehaviour 文件代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class SetBoolBehaviour : StateMachineBehaviour
{
public string boolName;
public bool updateOnStateMachine;
public bool updateOnState;
public bool valueOnEnter, valueOnExit;
// OnStateEnter is called before OnStateEnter is called on any state inside this state machine
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (updateOnState)
{
animator.SetBool(boolName, valueOnEnter);
}
}

// OnStateUpdate is called before OnStateUpdate is called on any state inside this state machine
//override public void OnStateUpdate(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
//{
//
//}

// OnStateExit is called before OnStateExit is called on any state inside this state machine
override public void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
{
if (updateOnState)
{
animator.SetBool(boolName, valueOnExit);
}
}

// OnStateMove is called before OnStateMove is called on any state inside this state machine
//override public void OnStateMove(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
//{
//
//}

// OnStateIK is called before OnStateIK is called on any state inside this state machine
//override public void OnStateIK(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
//{
//
//}

// OnStateMachineEnter is called when entering a state machine via its Entry Node
override public void OnStateMachineEnter(Animator animator, int stateMachinePathHash)
{
if (updateOnStateMachine)
{
animator.SetBool(boolName, valueOnEnter);
}
}

// OnStateMachineExit is called when exiting a state machine via its Exit Node
override public void OnStateMachineExit(Animator animator, int stateMachinePathHash)
{
if (updateOnStateMachine)
{
animator.SetBool(boolName, valueOnExit);
}
}
}

PlayerController 文件更新如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;

[RequireComponent(typeof(Rigidbody2D), typeof(TouchingDirections))]
public class PlayerController : MonoBehaviour
{
public float walkSpeed = 5f;
public float runSpeed = 8f;
public float jumpImpulse = 10f;
public float airWalkSpeed = 3f;
Vector2 moveInput;
TouchingDirections touchingDirections;

public float CurrentMoveSpeed
{
get
{
if (CanMove && IsMoving && !touchingDirections.IsOnWall)
{
if (touchingDirections.IsGrounded)
{
if (IsRunning)
{
return runSpeed;
}
else
{
return walkSpeed;
}
}
else
{
return airWalkSpeed;
}
}
else
{
return 0;
}
}
}

[SerializeField]
private bool _isMoving = false;
[SerializeField]
private bool _isRunning = false;

public bool IsMoving
{
get
{
return _isMoving;
}
private set
{
_isMoving = value;
animator.SetBool(AnimationStrings.isMoving, value);
}
}

public bool IsRunning
{
get
{
return _isRunning;
}
private set
{
_isRunning = value;
animator.SetBool(AnimationStrings.isRunning, value);
}
}

private bool _isFacingRight = true;
public bool IsFacingRight
{
get
{
return _isFacingRight;
}
private set
{
if (_isFacingRight != value)
{
// Flip the local scale to make the player face the opposite direction
transform.localScale *= new Vector2(-1, 1);
}
_isFacingRight = value;
}
}

public bool CanMove
{
get
{
return animator.GetBool(AnimationStrings.canMove);
}
}

Rigidbody2D rb;
Animator animator;

private void Awake()
{
rb = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
touchingDirections = GetComponent<TouchingDirections>();
}

private void FixedUpdate()
{
rb.velocity = new Vector2(moveInput.x * CurrentMoveSpeed, rb.velocity.y);

animator.SetFloat(AnimationStrings.yVelocity, rb.velocity.y);
}

public void OnMove(InputAction.CallbackContext context)
{
moveInput = context.ReadValue<Vector2>();
IsMoving = moveInput != Vector2.zero;
SetFacingDirection(moveInput);
}

private void SetFacingDirection(Vector2 moveInput)
{
if (moveInput.x > 0 && !IsFacingRight)
{
IsFacingRight = true;
}
else if (moveInput.x < 0 && IsFacingRight)
{
IsFacingRight = false;
}
}

public void OnRun(InputAction.CallbackContext context)
{
if (context.started)
{
IsRunning = true;
}
else if (context.canceled)
{
IsRunning = false;
}
}

public void OnJump(InputAction.CallbackContext context)
{
// TODO: Check if alive
if (context.started && touchingDirections.IsGrounded && CanMove)
{
animator.SetTrigger(AnimationStrings.jumpTrigger);
rb.velocity = new Vector2(rb.velocity.x, jumpImpulse);
}
}

public void OnAttack(InputAction.CallbackContext context)
{
if (context.started)
{
animator.SetTrigger(AnimationStrings.attackTrigger);
}
}
}

Knight Enemy

这一节将创建一个基本的敌人单位。

  1. 在 Assets/Art/FreeKnight/Colour1/NoOutline/120x80_PNGSheets 文件夹下找到 idle 文件,在 Inspector 窗口切换 Sprite Mode 为 Multiple
  2. 在 Sprite Editor 中对 idle 文件进行切割,切割方式为 Grid By Cell Count,可以通过 Pivot 选择图片中心
  3. 在 Hierarchy 窗口建立 KnightEnemy 对象,有两种建立方法,右键创建空物体然后在渲染器组件中拖入切割后的图像,或者直接把切割后的图像拖入 Scene 窗口中
  4. 在 Enemies 文件夹中创建 KnightEnemy 的预设体和 Animator Controller,后者命名为 AC_Knight
  5. 在 KnightEnemy 预设体中添加刚体组件并锁定 Z 轴旋转,添加 Animator 组件并设置 Controller 为 AC_Knight,添加 Capsule Collider 2D 组件和 Touching Directions 组件
  6. 选中 KnightEnemy 预设体,在 Inspector 中的 Layer 选项中选择 Add Layer,然后依次添加 Ground,Player,Enemy,PlayerHitBox 和 EnemyHitBox 层,它们分别占据 User Layer 的 6 到 10
  7. 将 Player,Grid 和 KnightEnemy 分别放到对应的层上
  8. 在 Edit 菜单打开 Project Settings 窗口,在 Physic 2D 窗口的 Layer Collision Matrix 中取消勾选 Player 和 Enemy 两两交互的三个选项
  9. 创建 Knight 脚本并作为组件添加到 KnightEnemy 预设体上

Knight 脚本的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[RequireComponent(typeof(Rigidbody2D), typeof(TouchingDirections))]
public class Knight : MonoBehaviour
{
public float walkSpeed = 3f;

Rigidbody2D rb;
TouchingDirections touchingDirections;

public enum WalkableDirection { Right, Left }
private WalkableDirection _walkDirection;
private Vector2 walkDirectionVector = Vector2.right;

public WalkableDirection WalkDirection
{
get { return _walkDirection; }
set
{
if (_walkDirection != value)
{
// Direction flipped
gameObject.transform.localScale = new Vector2(-1 * gameObject.transform.localScale.x, gameObject.transform.localScale.y);

if (value == WalkableDirection.Right)
{
walkDirectionVector = Vector2.right;
}
else if (value == WalkableDirection.Left)
{
walkDirectionVector = Vector2.left;
}
}
_walkDirection = value;
}
}

private void Awake()
{
rb = GetComponent<Rigidbody2D>();
touchingDirections = GetComponent<TouchingDirections>();
}

private void FixedUpdate()
{
if (touchingDirections.IsGrounded && touchingDirections.IsOnWall) { FlipDirections(); }
rb.velocity = new Vector2(walkSpeed * walkDirectionVector.x, rb.velocity.y);
}

private void FlipDirections()
{
if (WalkDirection == WalkableDirection.Right)
{
WalkDirection = WalkableDirection.Left;
}
else if (WalkDirection == WalkableDirection.Left)
{
WalkDirection = WalkableDirection.Right;
}
else
{
Debug.LogError("Current walkable direction is not set to legal values of right or left");
}
}

// Start is called before the first frame update
void Start()
{

}

// Update is called once per frame
void Update()
{

}
}

References

  1. Learn How to Make a 2D Platformer in Unity 2022 - FULL GAMEDEV COURSE!