2. 创建项目

常用包需要使用的时候导入,不需要使用的时候就吧它删除。

这样子的话就可以提高它的编译速度和整体项目大小的减低。

需要的内容如下,后面需要的包,后续再添加。

Test framework这个框架允许开发者为他们的游戏或应用编写自动化测试,包括单元测试、集成测试等,以确保代码的正确性和稳定性。

TextMeshPro:新版文字

Timeline适用于制作电影级的过场动画、复杂的用户界面动画、角色动画序列等。

Unity UIUGUI

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更精确地近似于对象的中心位置,特别是当对象不对称时。

这里有几个关键点:

  1. 边界框的概念Renderer.bounds提供的是一个轴对齐的边界框(AABB, Axis-Aligned Bounding Box),这意味着它不会旋转以适应对象的实际形状,而是保持与坐标轴平行。因此,它可能不是最紧凑的包围盒,但对于快速计算和碰撞检测等用途来说已经足够。

  2. 与局部空间的区别Mesh.boundslocalBounds类似于Renderer.bounds,但它们返回的是局部空间中的边界框。局部空间是指相对于对象自身的变换而言的空间,而不是世界空间。

  3. 自定义边界框:你可以通过设置自己的世界空间边界框来覆盖默认的边界框。这在渲染器使用执行自定义顶点变形的着色器时特别有用,因为默认的边界框在这种情况下可能不准确。当你设置了自定义的世界边界框后,渲染器的边界体积将不再自动跟踪Transform组件的变化。

  4. 重置边界框:如果同时存在局部空间边界框覆盖(localBounds),那么它会被忽略,并使用自定义的世界空间边界框。要移除自定义边界框覆盖,可以使用ResetBounds()方法。需要注意的是,自定义的世界边界框值不会被保存到场景或预制体中,必须在运行时通过脚本设置。

  5. 应用场景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的触发条件?

  1. 双方必须都有 Collider2D 组件

    • 两个游戏对象都必须附加 2D 碰撞器(如 BoxCollider2DCircleCollider2D 等)。

    • 其中一个碰撞器需勾选 Is Trigger(设置为触发器),另一个可以是普通碰撞器或触发器。

  2. 至少一方需要 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)。

特性

class

struct

类型

引用类型

值类型

内存分配

栈(通常)

赋值行为

复制引用

复制值

继承

支持

不支持

默认构造函数

可自定义无参构造函数

不能自定义无参构造函数

空值

可为 null

不能为 null(除非可空类型)

适用场景

复杂对象、共享数据

轻量级、不可变数据

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代用