2. 创建项目
常用包需要使用的时候导入,不需要使用的时候就吧它删除。
这样子的话就可以提高它的编译速度和整体项目大小的减低。
需要的内容如下,后面需要的包,后续再添加。
Test framework:这个框架允许开发者为他们的游戏或应用编写自动化测试,包括单元测试、集成测试等,以确保代码的正确性和稳定性。
TextMeshPro:新版文字
Timeline:适用于制作电影级的过场动画、复杂的用户界面动画、角色动画序列等。
Unity UI:UGUI
Burst:高性能编译器包,优化 Job System 中的作业(Jobs)。提高编译速度
Collections :高性能计算和数据管理设计。这个包提供了一系列高效的集合类和数据结构,这些数据结构特别适合用于 Unity 的 Job System 和 Entity Component System (ECS)。通过使用 Collections 包,你可以更好地管理数据,提高代码的性能和可维护性。
3. 导入资源
xxx@20:表示这张精灵是可以切割的,它的像素单位是20
保存预设
为了让多种图片拥有同样的设置,我们应该保存预设
切割图片
4. 创建player
为了实现渲染与逻辑分离,我们的第一个Player是一个空对象。后续添加逻辑脚本。
为了让角色能够很好的与其他物体进行遮罩关系,我们可以为Player添加Sortring Group组件。
并设置Sorting Layer层级,添加Instance层级(这个层级可以对角色进行遮挡交互相关的物体)
为了让人物通过轴心点y轴位置进行交互,需要自定义轴的渲染顺序
注意:上图的层次表示按照Sprite Sort Point的Y轴去判断(Sprite Renderer组件的属性)。
如果两张图片的Sprite Sort Point所在坐标谁的y轴越大(即下面的会遮挡上面的)
5. 角色的基本移动
因为整个项目是俯视角项目,我们只会为角色的脚部添加碰撞体,然后继续添加RigidBody,并且把重力设置为0
在新输入系统中,我们通过下面的代码去拿到它的输入。而且同时按下上右,会返回0.71,0.71。
_inputDirection = _playerInput.currentActionMap["Move"].ReadValue<Vector2>();
因为我们使用RigidBody进行移动,所以移动的逻辑我写在FixedUpdate里面。
我们通过当前MovePosition方法去移动,核心的思路是当前的位置 + 每帧的偏移位移。而位移=方向 * 速度 * 时间
_rigidbody2D.MovePosition(_rigidbody2D.position + _inputDirection * (moveSpeed * Time.deltaTime));
6. 基本的地图结构
同时为每一个TileMap设置相应的Sortring Layers
每个场景都会有一些固定的内容,为了做起了来更加的方便,我们可以使用场景嵌套的方式。在一个场景中(不变的)嵌套其他的场景(变的)
层级分析:
Ground Bottom:地板。土地
Ground Middle:草地。水池
Ground Top:石头、草
Instance:树
7. 地图绘制方法和技巧
没有什么技巧,想怎么绘制就怎么绘制
解决瓦片之间的缝隙
创建图集。
复习图集面板属性
Padding:图集中各图片之间的像素
Filter Mode:如何对纹理进行缩放处理。
Point:纹理靠近时变为块状
Bilinear:纹理靠近时变得模糊
Trilinear:与Bilinear类似,但是纹理在不同的MIP级别之间模糊
8. 摄像机跟随
使用Cinemachine相机(先导入包)
为了确保游戏画面在任何分辨率下都保持清晰、无模糊,并且尽可能接近设计师原定的设计尺寸。所以为主摄像机添加Pixel Perfect Camera组件
学习Cinemachine 2D相关
当我们创建2D Cinemachine 时,实际上是创建的虚拟相机。这个虚拟相机是一个空的对象,可以理解为配置表。他会相机的设置映射到主摄像中。
Follow:要跟随的对象
死区:角色可以移动,但摄像机不会跟随的区域
设置阻尼可以让摄像机有一种平滑效果
设置前瞻可以让我们提前看到多远的画面
9. 碰撞层和景观树
10. 摄像机边界
使用Cinemachine Confiner去确定摄像机的边界,为此我们需要添加边界碰撞体。
因为每个场景的边界是不一样的,所以我们通过代码去动态设置碰撞体。
Cinemachine Confiner的作用:
限制相机位置:通过定义一个2D形状(可以是一个多边形),你可以指定相机的移动范围。这意味着相机只能在这个范围内移动,超出这个范围的部分将被裁剪掉。
支持不同类型的相机:无论是正交(Orthographic)相机还是透视(Perspective)相机都可以使用这个功能,但有一个条件——相机必须直接面对这个2D形状(即相机的前方需与形状垂直)。
缓存机制
为了提高效率,系统会记住之前计算的结果(缓存)。但是,在上述情况下,你需要手动告诉系统清除这些记忆(通过调用InvalidateCache()
方法),以便系统知道需要重新计算。
private void SwitchConfinerShape()
{
PolygonCollider2D polygonCollider = GameObject.FindGameObjectWithTag("BoundsConfiner").GetComponent<PolygonCollider2D>();
CinemachineConfiner confiner = GetComponent<CinemachineConfiner>();
confiner.m_BoundingShape2D = polygonCollider;
confiner.InvalidatePathCache();
}
11. 景观物体遮挡半透明
方法:当人物走入景观,修改SpriteRenderer的颜色alpha值,然后人物走出景观,在修改它的alpha值
那么如何判断人物走进景观?设置碰撞器-触发器,通过触发函数触发。
那么如何进行颜色渐变?我们可以使用DoTween
DoTween
官方文档:https://dotween.demigiant.com/documentation.php
Color targetColor = new Color(1, 1, 1, 1);
// 通过DOColor设置颜色渐变
_spriteRenderer.DOColor(targetColor, Settings.fadeDuration);
全局变量
有一些属性我们可以在外部修改
设置一个Settings类,里面全是一些变量
public class Settings
{
public const float fadeDuration = 0.35f;
public const float targetAlpha = 0.45f;
}
12-18. 使用UIToolKit制作编辑器
19. 背包逻辑
InventoryManager背包管理器
通过id拿到对应的物品Details
Item类:挂载到物品下的脚本
有物品id,然后InventoryManager通过物品id拿到对应的精灵信息,渲染到SpriteRenderer上。
更新精灵加载后碰撞器的大小和位置。
这里有一个难点?如何获得精灵的大小?如何设置碰撞体的大小和位置
_boxCollider.size = new Vector2(_spriteRenderer.bounds.size.x, _spriteRenderer.bounds.size.y);
_boxCollider.offset = new Vector2(0, _spriteRenderer.bounds.size.y / 2);
解读Renderer.bounds
在Unity中,Renderer.bounds
属性提供了渲染器在世界空间中的边界框(axis-aligned bounding box)。这个边界框完全包围了对象在世界空间中的位置。使用bounds
可以方便地对对象的位置及其范围进行粗略估计。例如,center
属性通常比Transform.position
更精确地近似于对象的中心位置,特别是当对象不对称时。
这里有几个关键点:
边界框的概念:
Renderer.bounds
提供的是一个轴对齐的边界框(AABB, Axis-Aligned Bounding Box),这意味着它不会旋转以适应对象的实际形状,而是保持与坐标轴平行。因此,它可能不是最紧凑的包围盒,但对于快速计算和碰撞检测等用途来说已经足够。与局部空间的区别:
Mesh.bounds
和localBounds
类似于Renderer.bounds
,但它们返回的是局部空间中的边界框。局部空间是指相对于对象自身的变换而言的空间,而不是世界空间。自定义边界框:你可以通过设置自己的世界空间边界框来覆盖默认的边界框。这在渲染器使用执行自定义顶点变形的着色器时特别有用,因为默认的边界框在这种情况下可能不准确。当你设置了自定义的世界边界框后,渲染器的边界体积将不再自动跟踪
Transform
组件的变化。重置边界框:如果同时存在局部空间边界框覆盖(
localBounds
),那么它会被忽略,并使用自定义的世界空间边界框。要移除自定义边界框覆盖,可以使用ResetBounds()
方法。需要注意的是,自定义的世界边界框值不会被保存到场景或预制体中,必须在运行时通过脚本设置。应用场景:
bounds
对于快速判断物体的大致位置、大小以及是否可能发生碰撞非常有用。它可以帮助开发者高效地管理游戏中的物理交互、相机视锥剔除等方面的问题。
// _spriteRenderer.bounds.center 边界框的中心点
// _spriteRenderer.bounds.size 边界框的尺寸
20. 拾取物品
ItemPickUp类:
玩家有一个触发器,玩家触发到物品,判断该物品是否可以被拾取,如果可以被拾取,调用InventoryManager.AddItem方法(就会将物品添加到背包中)
/// <summary>
/// 将物品添加到背包中
/// </summary>
public void AddItem(Item item, bool toDestory = true)
{
if (toDestory)
{
Destroy(item.gameObject);
}
}
难点:Unity中OnTriggerEnter2D的触发条件?
双方必须都有 Collider2D 组件
两个游戏对象都必须附加 2D 碰撞器(如
BoxCollider2D
、CircleCollider2D
等)。其中一个碰撞器需勾选 Is Trigger(设置为触发器),另一个可以是普通碰撞器或触发器。
至少一方需要 Rigidbody2D 组件
至少一个游戏对象需要附加 Rigidbody2D 组件(动态或运动学类型均可)。
如果双方都没有 Rigidbody2D,触发器事件不会被触发。
21. 背包数据结构
我们只需要物品的Id和物品的数量
因为后续可能会与箱子,所以我们可以创建ScriptableObject来设置背包的数据。
定义InventoryItem数据
namespace Details
{
[System.Serializable]
public struct InventoryItem
{
public int itemID;
public int itemAmount;
}
}
难点:为什么InventoryItem使用struct而不使用class?
1. 类型本质:
class
是引用类型,变量存储的是对象的引用(内存地址)。
struct
是值类型,变量直接存储数据的值。
2.内存分配
class
:
实例分配在**堆(Heap)**上,由垃圾回收器(GC)管理内存。struct
:
实例通常分配在**栈(Stack)**上(但如果作为类的成员或装箱时会分配到堆中),生命周期由作用域决定,效率更高。
3. 默认构造函数
class
:
可以显式定义无参构造函数;如果没有定义,编译器会自动生成一个。struct
:
不能定义无参构造函数(编译器始终自动生成一个,且所有字段初始化为默认值)。
4. 赋值行为
// class 修改一个变量会影响另一个变量
var a = new MyClass { Value = 10 };
var b = a;
b.Value = 20; // a.Value 也会变成 20
// MyStruct 修改一个变量不会影响第另一个变量
var a = new MyStruct { Value = 10 };
var b = a;
b.Value = 20; // a.Value 仍为 10
5. 继承与多态
class
:
支持继承(单继承)和多态,可以派生自其他类或实现接口。struct
:
不能继承其他类或结构体,但可以实现接口。
6. 默认值
class
:
未初始化的变量为null
。struct
:
未初始化的变量会调用默认构造函数,所有字段初始化为其类型的默认值(如int
为 0)。
22. 实现物品检查和添加物品
InventoryManager类中
我们需要将将物品添加到背包的逻辑提取为一个方法(在指定位置添加物品),因为不仅在拾取物品的时候会添加,在交易的时候的时候也会添加。
方法参数需要:添加到背包的哪一个位置索引(如果索引为-1,背包没有这个物品),物品的id,物品的数量(交易的时候,物品的数量可能不是1)
如果背包没有相同的物品(index=-1)并且有空位,去找空位,找到空位后,将物品添加到背包中
如果背包中有相同的物品(索引不为-1),物品的数量累加,然后将物品添加到背包中
23-24. 背包UI
创建一个新的场景的UI,使用叠层的方式,来在场景中出现。
Horizontal Layout Group组件
水平排列
怎么取消Horizontal Layout Group组件的影响?
添加LayoutElement组件,并勾选Ignore Layout,该对象就不会受到Horizontal Layout Group的影响
25. 根据数据显示图片和数量
每个卡槽都有一个脚本SlotUI:用于动态图片和数量。那么我们应该拿到卡槽相关的对象,如数量文本、精灵图片、按钮、高亮对象。还需要去设置一个布尔值,用于判断该卡槽是否被激活,激活后,显示高亮。
因为卡槽可能是背包的、商店的、箱子的,所以我们可以定义一个枚举,然后在SlotUI脚本中定义一个枚举类型,用来表示该卡槽是什么类型的。
提供2个方法
初始化空的格子:如果被激活,设置高亮为false。设置文本为空,图片为空白图片,按钮不可交互
装饰格子:设置数量、图片,以及按钮为可交互
如何取消键盘导航按钮?
将button按钮下的Navigation设置为NULL
26. 背包UI的显示
InventoryUI类:控制所有的UI和数据之间的连接
拿到所有的物品UI的格子,注意添加顺序(首先先添加到ActionBar中,ActionBar满了才会添加到背包中)
物品UI数据数据必须与之间建立的背包的ScriptableObject依依对应,所以卡槽的物品都需要有一个序号
我们在添加物品后,更新UI,为了后续我们需要设置更新哪个地方的UI,需要设置地方的枚举
为了实现数据与UI之间的解耦,我们可以制作一个事件中心,当订阅数据变化,当数据变化后,告诉UI数据变化了,然后更新UI面板信息。
难点设计:如何制作事件中心?
思考?需要存储什么事件?添加物品后,将其放在背包中。
public static class EventHandler
{
public static event Action<InventoryLocation, List<InventoryItem>> OnInventoryChanged;
private static void OnOnInventoryChanged(InventoryLocation arg1, List<InventoryItem> arg2)
{
OnInventoryChanged?.Invoke(arg1, arg2);
}
}
注册事件。
private void OnEnable()
{
EventHandler.OnInventoryChanged += EventHandlerOnOnInventoryChanged;
}
private void OnDisable()
{
EventHandler.OnInventoryChanged -= EventHandlerOnOnInventoryChanged;
}
private void EventHandlerOnOnInventoryChanged(InventoryLocation location, List<InventoryItem> items)
{
// 告诉事件中心,触发事件触发后,你需要帮助我执行下面的逻辑
switch (location)
{
case InventoryLocation.Player:
for (int i = 0; i < items.Count; i++)
{
if (items[i].itemAmount > 0)
{
var itemDetails = InventoryManager.Instance.GetItemDetails(items[i].itemID);
slotUis[i].UpdateSlot(itemDetails, items[i].itemAmount);
}
else
{
slotUis[i].UpdateEmptySlot();
}
}
break;
case InventoryLocation.Box:
break;
}
}
通知事件
EventHandler.InventoryUITrigger(InventoryLocation.Player, itemBagSo.inventoryItems);
27. 控制背包的打开和关闭
按B或者鼠标点击打开或关闭,所以说需要一个布尔值去控制。
// 通过这个去判断场景中激活情况
isOpen = packGameObject.activeInHierarchy;
28. 背包物品选择高亮显示和动画
UI格子有东西才会被点击,选中一个,其他的所有的格子被取消
29. 实现物品拖拽,跟随显示
拖拽的功能:点击物品开始拖拽后,我们要在屏幕上显示要拖拽的物品,取消拖拽后,屏幕上拖拽的物品取消。
那么我们就要制作一个屏幕上显示的拖拽物品,拖拽时显示,取消拖拽后隐藏。
30. 拖拽交换数据,在地图上生成物品
取消拖拽后,检测射线检测到的UI元素如果不为空,判断该UI元素上是否有物品,如果有就交换,如果没有,就存放。
交换逻辑应该在InventoryManger代用
评论区