文章目录
  1. 1. 前言
  2. 2. 创建项目
  3. 3. URP
    1. 3.1. 要求和兼容
      1. 3.1.1. Render pipeline compatibility
      2. 3.1.2. Graphics API Compatibility
      3. 3.1.3. System Requirements
    2. 3.2. URP Rendering
      1. 3.2.1. URP Renderer
      2. 3.2.2. Shading models in URP
      3. 3.2.3. Camera
      4. 3.2.4. URP Asset
  4. 4. 输入系统
  5. 5. 正交摄像机Size选择
  6. 6. Sprite Flight小游戏
    1. 6.1. 自适应边界
  7. 7. 2D搜集金币小游戏
    1. 7.1. 创建角色
      1. 7.1.1. 导入Spine资源
      2. 7.1.2. 加载Spine并播放动画
      3. 7.1.3. 控制Spine移动
      4. 7.1.4. Spine添加碰撞体
    2. 7.2. 制作场景
      1. 7.2.1. Tilemap
        1. 7.2.1.1. 安装Tilemap
        2. 7.2.1.2. 创建Tilemap
        3. 7.2.1.3. Tile Palette
        4. 7.2.1.4. Using Tile Asset
        5. 7.2.1.5. Tilemap渲染顺序
        6. 7.2.1.6. Tilemap碰撞
    3. 7.3. 摄像机
      1. 7.3.1. 摄像机渲染
      2. 7.3.2. 摄像机跟随
      3. 7.3.3. 摄像机移动范围限制
    4. 7.4. 玩法
      1. 7.4.1. 随机刷道具
      2. 7.4.2. 玩家碰撞道具搜集
      3. 7.4.3. 道具信息HUD
    5. 7.5. 音乐和音效
  8. 8. Reference

前言

本章节是深入2D游戏开发学习,深入学习2D游戏开发中需要学习的知识,同时学习Unity 6000版本+URP的一些相关知识。

用到的东西:

Unity6000.3.19f1,CodeX,VSCode,Copilot,URP

创建项目

Unity Hub->新建项目->Universal 2D Template

URP

The Universal Render Pipeline (URP) is a prebuilt Scriptable Render Pipeline, made by Unity. URP provides artist-friendly workflows that let you quickly and easily create optimized graphics across a range of platforms, from mobile to high-end consoles and PCs.

从介绍看URP是提供了美术工作流友好,可以快速创建跨平台的可编程渲染管线。

要求和兼容

Render pipeline compatibility

Projects made using URP are not compatible with the High Definition Render Pipeline (HDRP) or the Built-in Render Pipeline. Before you start development, you must decide which render pipeline to use in your Project.

从介绍可以看出URP和HDRP或内置渲染管线不兼容,在开发一开始就要选好使用URP还是HDRP或者内置渲染管线。

Graphics API Compatibility

  • DirectX 11 (feature level 11_0 and later)
  • DirectX 12
  • Vulkan
  • Metal
  • OpenGL ES 3.0 and later
  • OpenGL Core
  • WebGL2
  • WebGPU

看得出URP对渲染版本要求还是比较新的,PC和移动端至少要OpenGL ES 3.0,但就目前2026/09/10这个时间点来说,个人觉的不考虑兼容太多旧机器,大部分发达国家和中国都能大部分满足。

System Requirements

关于开发机器的系统要求详情参考:

System requirements for Unity 6.3

这里主要关注下移动端运行时的系统要求:

Operating system Operating system version CPU Graphics API Additional requirements
Android 7.1 (API 25)+ Customized versions of Android must include all supported Google standard APIs. ARMv7 with Neon Support (32-bit) or ARM64 OpenGL ES 3.0+, Vulkan 1GB+ RAMSupported hardware devices must meet or exceed Google’s Android Compatibility Definition (Version 9.0) limited to the following Device Types:Handheld (Section 2.2)Television (Section 2.3)Tablets (Section 2.6)Hardware must be running Android OS natively. Android within a container or emulator isn’t supported.For development: Android SDK (15/API 35), Android NDK (r27c) and OpenJDK (17), which are installed by default with Unity Hub.
iOS/iPadOS 15+ A8 SoC+ Metal Xcode version 16 or later.For development and debugging: refer to Apple documentation on Xcode support.For App Store submission: refer to Apple’s submission guidelines for the required Xcode version.

看起来移动端运行时系统要求也还好,就2026/09/10这个时间点来说大部分国家应该都能满足。

URP Rendering

URP渲染场景通过以下组件:

  1. URP Renderer
  2. Shaing models for shaders shiiped with URP
  3. Camera
  4. URP asset

URP渲染流程预览:

URPRenderingLoop

如果想将非URP的Asset转换到URP的Asset(比如Shader),官方提供了Render Pipeline Converter Window,详情参考:

Convert assets and quality levels from the Built-In Render Pipeline to URP

关于内置Shader转换到URP Shader的详细说明参考:

Convert custom shaders for URP compatibility

先不说转换问题,URP默认就提供了一系列的Shader供我们使用,比如我们原来使用的UI/Default和Sprite/Default和Particles下的Shader在URP下分别对应关系如何:

UI/Default保持不变依然可以继续使用

Sprite/Default对应Universal Render Pipeline/2D/Sprite-Unlit-Default(不受光照)或者Universal Render Pipeline/2D/Sprite-Lit-Default(受光照)

Particles对应Universal Render Pipeline/Unlit(配合设置不同的Surface Type,Blending Mode,Color Mode等)

具体尝试了下好像直接替换恢复Base Map和Tink Color啥的并不能直接正确显示,后续准备直接下载支持URP的特效来使用,这里先不处理特效Shader升级问题了

URP Renderer

URP Renderer包含以下两个方式:

  1. Universal Renderer
  2. 2D Renderer

Shading models in URP

A shading model defines how a Material’s color varies depending on factors such as surface orientation, viewer direction, and lighting. Your choice of a shading model depends on the artistic direction and performance budget of your application.

从介绍来看Shading models有点像光照模型选择,会决定如何计算最终渲染颜色。

URP包含以下Shading models:

  1. Physically Based Shading
  2. Simple Shading
  3. Baked Lit Shading
  4. No lighting

Camera

URP里的Camera的Render Type有两类:

  1. Base

    general purpose Camera that renders to a render target (a screen, or a Render Texture)

    个人理解对应原来Camera的非overlay方式,就是之前最常见的场景摄像机方式

  2. Overlay

    renders on top of another Camera’s output. You can combine the output of a Base Camera with the output of one or more Overlay Cameras. This is called Camera stacking.

    个人理解对应原来Camera的overlay方式,在Base之后渲染,利用添加到Base Camera Stack上实现先渲染Base Camera再渲染Overlay画面的效果,个人这个对应之前UI摄像机的方式。

选择Base后,再下方Enviroment的Background Type对应了原来的Clear Flag选项设置:

BaseCameraBackgroundTypeSetting

因为URP的Base Camera只有Skybox和Solid Color选项,要模拟以前UI摄像机选择Depth Only的效果,我们需要将URP的Camera设置成Overlay+Clear Depth的设定:

URPUICameraInspectorSetting

但仅仅这样设置UI摄像机还不够,还无法正常渲染UI效果。在URP里Overlay摄像机要想渲染还需要添加到Base Camera的Stack里才行,这里一个Camera Stack概念需要先了解下。

首先Camera Stack是仅对URP的Camera设置成Base Render Type而言才有的。

A camera stack allows to composite results of several cameras together. The camera stack consists of a Base camera and any number of additional Overlay cameras.

You can use the stack property add Overlay cameras to the stack and they will render in the order as defined in the stack.

从介绍可以看出,Camera Stack适用于组装不同摄像机渲染顺序的一个概念,所以Overlay的UI摄像机要想正确显示我们还需要将UI Camera添加到Base Camera的Stack里:

AddBaseCameraStack

至此终于能看到UI画面渲染了:

UIOverlayCameraRendering

这里遇到一个问题,Overlay Camera借助Base Camera Stack渲染叠加,但没法像以前一样选中对应摄像机进行Scene场景然选画面预览了,有知道如何解决的请告知一声。

OverlayCameraScenePreviewFailed

Note:

  1. URP摄像机必须要有UniversalAdditionalCameraData组件
  2. Overlay摄像机必须添加到Base Camera Stack里进行渲染输出

URP Asset

常规的URP配置需要我们自己创建URP Asset并在Edit->Project Settings->Graphics->Set Default Render Pipeline Asset指定,但由于我是直接通过Universal 2D Template创建的项目,所以默认Edit->Project Settings->Quality里所有Levels都配置好了Render Pipeline Asset:

SetDefaultRenderPipelineAssetArea

QualityRenderPipelineAssetArea

这里简单看下Universal Render Pipeline Asset的Inspector面板参数:

UniversalRenderPipelineAssetInspector

可以看到URP Asset里指定了一个Renderer2D(Renderer 2D Data)的Asset作为渲染器数据(第0个是默认选择):

也就是说将使用Renderer 2D作为摄像机的默认URP渲染。

URP摄像机是否使用这个Renderer 2D作为渲染器是在Camera的Renderer上设置的:

URPCameraRendererSettingInspector

简单看下Renderer 2D Data的Inspector面板:

Renderer2DDataInspector

输入系统

老版Unity用的是老的输入系统,我们会给EventSystem组件挂载StandloneInputModule组件,而新Input System需要挂在InputSystemUIInputModule组件:

NewInputSystemUIInputModule

老版输入系统直接访问UnityEngine.Input的相关接口,新版Input System要访问UnityEngine.InputSystem下的相关类和接口。

比如检测左键鼠标按下:

老版Input System代码:

1
Input.GetMouseButtonDown(0)

新版Input System代码:

1
Mouse.current.leftButton.wasPressedThisFrame

比如检测移动端手指按下检测:

老版Input System代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
var touchNum = Input.touchCount;
if (touchNum > 0)
{
for (int i = 0; i < touchNum; i++)
{
Touch touch = Input.GetTouch(i);
// 检测手指松开(完成点击)
if (touch.phase == TouchPhase.Ended)
{
PlayEffectAtScreenPos(touch.position);
}
}
}

新版Input System代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
using TouchPhase = UnityEngine.InputSystem.TouchPhase;

var touches = Touch.activeTouches;
for (int i = 0; i < touches.Count; i++)
{
Touch touch = touches[i];

if (touch.phase == TouchPhase.Ended)
{
Vector2 screenPosition = touch.screenPosition;
Debug.Log($"触摸开始:{screenPosition}");
}
}

而且新版Input System直接提供了像InputAction和EnhancedTouch这类更高级的输入抽象:

比如用InputAction监听鼠标点击和屏幕点击:

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
/// <summary>
/// 鼠标与触摸共用的点击动作
/// </summary>
private InputAction mClickAction;

/// <summary>
/// 创建点击动作并注册回调
/// </summary>
private void Awake()
{
mClickAction = new InputAction();
mClickAction.AddBinding("<Mouse>/leftButton");
mClickAction.AddBinding("<Touchscreen>/primaryTouch/press");
mClickAction.performed += HandleClick;
}

/// <summary>
/// 解除回调并释放动作资源
/// </summary>
private void OnDestroy()
{
mClickAction.performed -= HandleClick;
mClickAction.Dispose();
}

/// <summary>
/// 获取触发点击的设备位置并处理业务
/// </summary>
/// <param name="context">本次点击的输入上下文</param>
private void HandleClick(InputAction.CallbackContext context)
{
Vector2 screenPosition;
if (context.control.device is Touchscreen touchscreen)
{
screenPosition = touchscreen.primaryTouch.position.ReadValue();
}
else if (context.control.device is Mouse mouse)
{
screenPosition = mouse.position.ReadValue();
}
else
{
return;
}

// 在这里处理点击逻辑,screenPosition 为屏幕像素坐标
Debug.Log($"点击位置:{screenPosition}");
}

比如用EnhancedTouch监听多指触碰检测缩放:

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
/// <summary>
/// 用于避免两指距离过小时产生不稳定的缩放比例
/// </summary>
private const float MinimumDistance = 1f;

/// <summary>
/// 是否正在跟踪双指手势
/// </summary>
private bool mIsTracking;

/// <summary>
/// 第一个触点的标识
/// </summary>
private int mFirstTouchId;

/// <summary>
/// 第二个触点的标识
/// </summary>
private int mSecondTouchId;

/// <summary>
/// 上一帧的两指距离,单位为屏幕像素
/// </summary>
private float mPreviousDistance;

/// <summary>
/// 缩放事件,参数依次为距离比例和两指的屏幕中心位置
/// 比例大于一表示张开,小于一表示靠拢
/// </summary>
public event Action<float, Vector2> ZoomRequested;

/// <summary>
/// 启用触摸记录并重置手势
/// </summary>
private void OnEnable()
{
EnhancedTouchSupport.Enable();
mIsTracking = false;
}

/// <summary>
/// 查找触点并计算双指距离变化
/// </summary>
private void Update()
{
if (!mIsTracking)
{
TryBeginTracking();
return;
}

Vector2 firstPosition;
Vector2 secondPosition;
bool hasFirstTouch = TryGetPosition(mFirstTouchId, out firstPosition);
bool hasSecondTouch = TryGetPosition(mSecondTouchId, out secondPosition);

if (!hasFirstTouch || !hasSecondTouch)
{
// 任一触点结束后重新选取,避免使用旧距离造成跳变
mIsTracking = false;
TryBeginTracking();
return;
}

float currentDistance = Vector2.Distance(firstPosition,secondPosition);

if (currentDistance < MinimumDistance || mPreviousDistance < MinimumDistance)
{
mPreviousDistance = currentDistance;
return;
}

float scale = currentDistance / mPreviousDistance;
mPreviousDistance = currentDistance;

if (Mathf.Approximately(scale, 1f))
{
return;
}

Vector2 center = (firstPosition + secondPosition) * 0.5f;
ZoomRequested?.Invoke(scale, center);
}

/// <summary>
/// 清理手势并配对释放触摸记录
/// </summary>
private void OnDisable()
{
mIsTracking = false;
EnhancedTouchSupport.Disable();
}

/// <summary>
/// 选择两个仍按住的触点并记录初始距离
/// </summary>
private void TryBeginTracking()
{
var touches = EnhancedTouch.activeTouches;
bool hasFirstTouch = false;
Vector2 firstPosition = Vector2.zero;

for (int i = 0; i < touches.Count; i++)
{
EnhancedTouch touch = touches[i];
if (!IsPressed(touch))
{
continue;
}
if (!hasFirstTouch)
{
mFirstTouchId = touch.touchId;
firstPosition = touch.screenPosition;
hasFirstTouch = true;
continue;
}
mSecondTouchId = touch.touchId;
mPreviousDistance = Vector2.Distance(firstPosition,touch.screenPosition);
mIsTracking = true;
return;
}
}

/// <summary>
/// 获取指定触点的位置,忽略已结束或已取消的触点
/// </summary>
/// <param name="touchId">需要查找的触摸标识</param>
/// <param name="position">触点的屏幕像素坐标</param>
/// <returns>
/// 是否找到仍按住的触点
/// </returns>
private bool TryGetPosition(int touchId, out Vector2 position)
{
var touches = EnhancedTouch.activeTouches;
for (int i = 0; i < touches.Count; i++)
{
EnhancedTouch touch = touches[i];
if (touch.touchId == touchId && IsPressed(touch))
{
position = touch.screenPosition;
return true;
}
}
position = Vector2.zero;
return false;
}

/// <summary>
/// 判断触点是否仍处于接触状态
/// </summary>
/// <param name="touch">需要检查的触点</param>
/// <returns>
/// 是否仍按住屏幕
/// </returns>
private bool IsPressed(EnhancedTouch touch)
{
return touch.phase == InputTouchPhase.Began
|| touch.phase == InputTouchPhase.Moved
|| touch.phase == InputTouchPhase.Stationary;
}

新版Input System支持了inputactions的配置文件:

InputActionAssetInspector

具体的配置面板:

InputActionAssetConfig

比如前面提到的InputAction监听按钮左键点击和屏幕点击的AddBinding路径信息在这里就能查看到:

UIClickLeftButtonConfigInfo

UIClickTouchPressConfigInfo

正交摄像机Size选择

一般来说2D游戏我们都不会有透视(近大远小)的需求,所以我们一般会选择Orthographic(正交)摄像机。

在理解正交摄像机Size选择和Sprite像素实际占屏幕空间大小之前,我们要理解一个概念PPU(Pixel Per Unit)。

PPU(Pixel Per Unit)决定图片在世界里有多大,正交摄像机Size决定画面能看到多大的世界,两者共同决定图片最终在屏幕显示多大。

PPU决定多少个素材像素,对应 1 个 Unity 世界单位。

精灵世界宽度 = 精灵素材宽度 / PPU
精灵世界高度 = 精灵素材高度 / PPU

比如我们PPU设置100,一张图刚好100X100大小像素,那么这张图放到Unity世界里就占1X1个世界单位大小。

正交摄像机的Size是指可见高度的一半,即可见世界高度 = 2 × Size

比如我们屏幕像素是1920X1080,此时可见高度像素是1080,假设我们希望Sprite的1个像素就正好对应屏幕显示的1个像素,也就意味着:可见世界高度X100=1080,反过来也就是说Size = 1080 / 2 / 100 = 5.4

所以我们将Sprite的PPU设置成100,正交摄像机Size设置成5.4,此时在1920X1080的屏幕上就刚好1个像素对应1个像素,且100X100的Sprite刚才显示成1个世界单位大小。

但考虑到屏幕像素大小是动态变得,如果希望在不同机型上都希望保持1个像素对应1个像素,那么就要根据屏幕高像素去动态计算正交摄像机的Size(Size = 屏幕高像素 / 2 / 100)

Sprite Flight小游戏

Sprite Flight是一个2D物理碰撞弹射游戏,我们会制作障碍物(Obstacle)和边界(Border)去阻碍和限制运动范围。

自适应边界

SpriteFlightBorders

可以看到我们制作了4条边作为限制运动范围的边界。

为了确保100个像素对应1个Unity单位,不同分辨率我们需要动态计算正交Size。

分辨率1920*1080,Size=1080/2/100=5.4

分辨率960*640,Size=640/2/100=3.2

确保了100个像素对应1个Unity单位后,每条边默认占了1个Unity单位(100像素)那么4条边要想准确的计算卡在屏幕边界的位置就等于:

左边边位置X=-1(左侧反方向) * Size * 宽高比 + 0.5(因为边自身占1个单位,以中心来算的)

右边边位置X=1(右侧正方向) * Size * 宽高比 - 0.5(因为边自身占1个单位,以中心来算的)

上边边位置Y=1(上边正方向) * Size - 0.5(因为边自身占1个单位,以中心来算的)

下边边位置Y=-1(下边反方向)* Size + 0.5(因为边自身占1个单位,以中心来算的)

以1920*1080分辨率来算:

左边边位置X=-1 * 5.4 * 16 / 9 +0.5=-9.1

右边边位置X=1 * 5.4 * 16 / 9 -0.5=9.1

上边边位置Y=1 * 5.4-0.5=4.9

下边边位置Y=-1 * 5.4+0.5=-4.9

分辨率1920*1080实际效果:

1920X1080EdgeAdaption

1920X1080CameraSize

1920X1080BorderLeftPosition

1920X1080BorderTopPosition

分辨率960*640实际效果:

960X640EdgeAdaption

960X640CameraSize

960X640BorderTopPosition

960X640BorderLeftPosition

可以看到正如我们前面计算的公式一样,摄像机正交Size个每条边的位置正如预期的值一致。

2D搜集金币小游戏

参考教程:

2D Adventure Game: Robot Repair

教程只是参考学习,实际并没有完全按照教程流程走,我个人以制作一个2D搜集金币的小游戏作为学习Demo,这里我个人没用SpriteRenderer+序列帧的方式,我直接使用Spine作为2D角色。

创建角色

Spine相关知识学习参考:

Spine学习

导入Spine资源

这里直接使用Spine包里的Demo官方的Spineboy作为Spine资源,用Spineboy资源制作角色Prefab。

ImportSpineResAndCreateSpinePrefab

Note:

  1. 导入Spine资源之前请先安装Spine包

加载Spine并播放动画

资源加载每个项目有自己的资源加载框架这里就不展示了,这里只要讲讲Spine的动画播放,这里我使用的SkeletonAnimation组件进行Spine动画播放控制:

1
SkelectonAnimation.AnimationState.SetAnimation(0, mPlayAnimName, mIsLoopAnim);

LoadSpineAndPlayAnimation

控制Spine移动

目前使用的是Unity 6000.3.19.f1版本,使用的是新版Input System,这里结合InputAction来实现WSAD(含上下左右)键盘点击和屏幕触摸控制操作的通用输入控制实现:

InputController.cs

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
/// <summary>
/// 键盘移动动作
/// </summary>
private InputAction mKeyboardMoveAction;

/// <summary>
/// 触摸移动动作
/// </summary>
private InputAction mTouchMoveAction;

/// <summary>
/// 点击动作
/// </summary>
private InputAction mClickAction;

/// <summary>
/// 当前正在处理的触摸点
/// </summary>
private TouchControl mActiveTouch;

mKeyboardMoveAction = new InputAction("KeyboardMove", InputActionType.Value,"Vector2");
mKeyboardMoveAction.AddCompositeBinding("2DVector")
.With("Up", "<Keyboard>/w")
.With("Down", "<Keyboard>/s")
.With("Left", "<Keyboard>/a")
.With("Right", "<Keyboard>/d")
.With("Up", "<Keyboard>/upArrow")
.With("Down", "<Keyboard>/downArrow")
.With("Left", "<Keyboard>/leftArrow")
.With("Right", "<Keyboard>/rightArrow");
mKeyboardMoveAction.started += HandKeyboardStartMoveAction;
mKeyboardMoveAction.performed += HandKeyboardMoveAction;
mKeyboardMoveAction.canceled += HandKeyboardMoveCanceledAction;

mTouchMoveAction = new InputAction("TouchMove", InputActionType.Value, "Vector2");
mTouchMoveAction.AddBinding("<Touchscreen>/primaryTouch/press");
mTouchMoveAction.started += HandleTouchStartMoveAction;
mTouchMoveAction.performed += HandleTouchMoveAction;
mTouchMoveAction.canceled += HandleTouchMoveCanceledAction;

mClickAction = new InputAction("Click", InputActionType.Button);
mClickAction.AddBinding("<Mouse>/leftButton");
mClickAction.AddBinding("<Touchscreen>/primaryTouch/press");
mClickAction.performed += HandleClickAction;

SetEnable(true);

/// <summary>
/// 设置输入控制器是否启用
/// </summary>
/// <param name="isEnable"></param>
public void SetEnable(bool isEnable)
{
mIsEnable = isEnable;
if(mIsEnable)
{
mKeyboardMoveAction.Enable();
mTouchMoveAction.Enable();
mClickAction.Enable();
EnhancedTouchSupport.Enable();
}
else
{
mKeyboardMoveAction.Disable();
mTouchMoveAction.Disable();
mClickAction.Disable();
EnhancedTouchSupport.Disable();
}
}

然后编写CCPlayerControlController.cs去响应输入时间进行角色控制移动:

CCPlayerControlController.cs

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
/// <summary>
/// CCPlayerControlController.cs
/// 手机金币玩家控制器的控制器,负责处理玩家的输入和控制逻辑
/// </summary>
public class CCPlayerControlController : BaseController
{
******

/// <summary>
/// 添加事件订阅
/// </summary>
private void AddEvents()
{
mWorld?.Events.AddListener(EventId.InputMoveStart, OnInputMoveStart);
mWorld?.Events.AddListener(EventId.InputMove, OnInputMove);
mWorld?.Events.AddListener(EventId.CancelInputMove, CancelInputMove);
mWorld?.Events.AddListener(EventId.InputZoom, OnInputZoom);
mWorld?.Events.AddListener(EventId.InputClick, OnInputClick);
}

/// <summary>
/// 响应更新
/// </summary>
/// <param name="deltaTime"></param>
public void Update(float deltaTime)
{
}

/// <summary>
/// 响应输入移动开始事件
/// </summary>
/// <param name="eventParam"></param>
private void OnInputMoveStart(EventParam eventParam)
{
if(!CollectCoinManager.Singleton.IsInGame())
{
return;
}
Debug.Log($"CCPlayerControlController响应输入移动开始事件触发");
UpdatePlayerStartMove();
}

/// <summary>
/// 响应输入移动事件
/// </summary>
/// <param name="eventParam"></param>
private void OnInputMove(EventParam eventParam)
{
if(!CollectCoinManager.Singleton.IsInGame())
{
return;
}
var moveDirection = ParamUtilities.ParseArg<Vector2>(eventParam.EventParams, 0);
Debug.Log($"CCPlayerControlController响应输入移动事件触发,moveDirection:{moveDirection}");
UpdatePlayerMove(moveDirection);
}

/// <summary>
/// 响应取消输入移动事件
/// </summary>
/// <param name="eventParam"></param>
private void CancelInputMove(EventParam eventParam)
{
Debug.Log($"CCPlayerControlController响应取消输入移动事件触发");
StopPlayerMove();
}

/// <summary>
/// 响应点击输入事件
/// </summary>
/// <param name="eventParam"></param>
private void OnInputClick(EventParam eventParam)
{
if(!CollectCoinManager.Singleton.IsInGame())
{
return;
}
var screenPosition = ParamUtilities.ParseArg<Vector2>(eventParam.EventParams, 0);
Debug.Log($"CCPlayerControlController响应点击输入事件触发,screenPosition:{screenPosition}");
PlayPlayerAttack();
}

******
}

目前我采用的Rigidbody2D设置速度的方式控制角色移动。

Spine添加碰撞体

给做好的Spine预制件添加CapuleCollider 2D并设置好合适的大小位置。

AddPlayerCapsuleCollider2D

后续还会给地图Tilemap加碰撞体,从而实现碰撞交互和限制移动范围。

制作场景

目前场景还是一片漆黑,制作场景前我们要学习一个2D游戏里的重要概念Tilemap

Tilemap

2D游戏的场景往往会使用Tilemap的技术进行场景编辑,那什么是Tilemap了?

A Tilemap consists of a grid overlay and a number of other components working together. The entire system allows you to paint levels using tiles and brush tools and define rules for how tiles behave. With Tilemaps, you can create platforms with dynamic edges, animated tiles, randomized tile placements, and more.

从介绍来看,Tilemap支持我们像绘画一样去使用tiles和brush工具去编辑关卡地图。

安装Tilemap

使用Tilemap前要先通过Package Manager安装对应包:

InstallTilemapPackage

创建Tilemap

相关文档:

Tilemaps

参考教程:

Introduction to Tilemaps

CreateTilemapFromMenu

Tilemap由两部分组成:

  1. Grid

    TilemapGridComponentInspector

    相关参数介绍:

    • Cell Size: 单个Square网格的大小配置
    • Cell Gap: Square网格之间的间隔
    • Cell Layout: 地块tiles的排版格式
    • Cell Swizzle: 网格的朝向配置
  2. Tilemap

    • Tilemap组件

      TilemapComponentInspector

      相关参数介绍:

      • Animation Frame Rate: 地块Tile的动画帧率
      • Color: 配置地块Tiles颜色
      • Tile Anchor: 配置地块Tile的锚点
      • Orientation: 地块Tile的朝向
    • Timemap Renderer组件

      TilemapRendererComponentInspector

      相关参数介绍:

      • Sort Order: 配置地块Tile渲染顺序(比如左下角开始)
      • Mode: 地块Tile渲染模式(比如Chunk Mode(支持Sprite Batch Renderer),Individual Mode(Sprite根据位置和Sort order决定渲染顺序))
      • Detect Chunk Culling: 配置Tilemap Renderer如何检测Chunk边界
      • Chunk Culling Bounds: 手动配置Chunk边界大小配置
      • Mask Interaction: 配置Tilemap和Mask的交互渲染模式
      • Material: 配置地块Tile渲染材质
      • Sorting Layer: 配置地块Tile渲染层级
      • Order in Layer: 配置地块Tile渲染Order值

Note:

  1. Grid和Tilemap是1对多关系,只允许场景里有1个Grid,但可以有多个Tilemap
Tile Palette

Tilemap里我们通过将Sprite制作成一个一个的Tile来编辑绘制地图,而Tile Palette(地块调色板)就是我们最常用到的工具。

打开Tile Pallette:

Window->2D->Tile Palette

TilePalletteWindow

使用Tile Pallette创建用于我们自己游戏的Tile Pallette,我们点击激活的Tile Pallette下拉框后选择Create New Tile Pallette:

CreateNewTilePaletteOperation

CreateNewPaletteUI

创建成功后会发现目前还没有任何的Tile内容:

CreateTilePaletteSuccess

在编辑添加Tile Asset之前,我们选中刚创建的Palette进行基础的Palette设置:

CollectCoinPaletteSettings

这里我想自定义Cell Size大小所哟Cell Sizing我设置成了Manual,同时我是正交照射2D显示所以配置了Sort Mode为Orthographic

然后选中Palette Prefab设置Grid的Cell Size到1,1,1:

SetCollectCoinPaletteCellSize

接下来我将免费资源:

Adventure Game: Robot Repair | 2D Sample Project里的一部分资源用来作为制作Tile Palette的资源

TilePaletteResourcePrepare

添加Sprite作为Tile(直接拖动Sprite到Tile Palette里):

1024X1024SpriteSetting

1024X1024TileInPalette

可以看到一张1024X1024的Sprite,在100PPU的情况下占Palette的10X10的大小,符合我们的预期,因为我们把Palette的Cell Size设置成了1X1也就是1X1个Unity单位,因为100PPU也就是1X1个Unity单位像素大小为100X100像素。

Palette的Grid Size设置和实际运行时设置的Grid Size可以不一致,Palette为了方便编辑,设置Automatic好像会自动取最大Sprite占1个格子的方式计算Grid Size,这样做的好处是Tile Palette排版很好排,不会像设置成Manual后1X1的Grid Size容易占多个格子重叠问题,缺点是格子很大,一些小图会很小看不清,但为了方便排版,最后我还是设置成了Automatic的方式。

最终完成Tile Asset的导入:

CollectCoinPaletteCompleteSpriteImport

Note:

  1. 自定义Tile Asset Create通过继承TileTemplate脚本进行编写(这个要走Assets->2D->Tile Palette->New Tile Set创建后方可设置)
Using Tile Asset

在前面我们创建好Tile Palette和对应的Tile Asset后,接下来我们就能通过Tile Map进行绘制我们的自定义地图了。

首先我们要单独创建一个Tilemap预制件:

CreateTilemapFromMenu

设置成我们想要的Cell Size大小:

SetTilemapCellSize

然后打开Tile Palette选中我们刚才创建的Tile Palette作为素材Palette:

ChooseTilePaletteToPaint

然后激活Tilemap的绘笔:

TilemapActivePainter

ActivePickTool

选中要绘制的Tile Asset在Scene里左键绘制:

SelectTileAssetToPaint

绘制地图完成:

CompleteTilemapEdit

要想编辑好的Tilemap显示的Spine人物模型之后,我们需要设置Tilemap Renderer组件的Sorting Layer和Order:

SetTilemapRendererLayerAndOrder

SetSpineRendererLayerAndOrder

然后把Tilemap编辑好的预制件和Spine模型预制件加载出来显示:

TilemapAndSpineCharacterRender

可以看到因为我把角色的Layer调整的比Tilemap高,所以角色显示在了上层。

但这样一来角色会凌驾于所以场景物体之上:

SpinePlayerRendererOrderProblem

Tilemap渲染顺序

像前面说到的Tile渲染层级问题,我们需要制作Tilemap时就规划好层级。

首先我们要拆分出Tilemap的背景层,中间层和顶层层级设计

2DRenderLayerPrepare

Background用于Tilemap背景,Middle用于中间层所有物体显示,Front用于显示在顶层的物体。

重新制作Tilemap,将背景,场景物体和顶层物体归类,分别创建3个Tilemap进行编辑:

NewTilemapEdit

将角色的Renderer Layer修改到Middle层(和场景物件显示在同一层):

NewSpineRendererLayerSetting

此时我们会发现,虽然场景物件和角色在同一层Layer渲染了,但玩家角色和场景物件没法根据玩家所在位置(Y值)决定是显示在场景物件前面还是后面:

TransparencySortModeProblem

为了解决这个问题,我们需要修改Camera渲染层级的依据方式,因为我创建的Universal 2D Template,摄像机默认用的Renderer2D Asset的渲染方式,我们需要将Renderer2D Asset的Transparency Sort Mode从Default(根据深度排序)修改成Custom Axis+Transparency Sort Axis(0,1,0):

CustomAxisTransparencySortModeSetting

上述设置表示我们的摄像机渲染顺序根据Y轴坐标比较来判定渲染先后顺序(同Layer同Order的情况下),最后我们就会得到角色如果在物件下方就渲染整个,角色在物件上方就只渲染未被遮挡部分:

CharacterStayBelowItem

CharacterStayAboveItem

SpinePlayerRendererOrderProblem

但是部分跟角色同Layer的Tile我发现遮挡层级并不完全正确:

TileRendererOrderProblem

这是因为我们物品Sprite的Pivot默认指定的Center:

TileCenterPivotProblem

摄像机根据Y比较渲染顺序是根据Tile所在位置的Y值来比较的,而Y值的值是根据Tile的Pivot所在位置的Y值来判定的。

修复后会发现Tile网上移动了一点,应该因为Pivot修改了影响了位置,但是和玩家的层级问题表现已经正确了:

TileRendererOrderProblemFix

Note:

  1. Tile的Sprite Pivot除了影响位置判定点,还影响旋转,旋转是围绕Pivot转的
Tilemap碰撞

Tilemap层级渲染正确了,接下来我们如何给Tilemap的Tile添加碰撞,从而实现Tile的阻挡,交互。

要给绘制好的Tilemap的Tile元素添加碰撞组件,我们需要给挂载Tilemap组件节点添加Tilemap Collisder 2D组件:

AddTilemapCollider2D

可以看到我给MiddleTilemap添加了Tilemap Collider 2D组件后,对应创建的Tile就自动构建了碰撞体。

运行游戏就会发现因为添加了碰撞体,场景物体就能阻挡角色移动穿透了:

TilemapCollider2DWorking

Tilemap Collider 2D的碰撞体构成类型默认是Composite Operation为None(每个Tile独立一个碰撞体),如果场景带碰撞体的物体多了,碰撞体会成为性能瓶颈。

优化Tilemap碰撞体问题我们可以使用Composition Collider 2D组件加Tilemap Collider 2D的Composite Operation为Merge(合并成一个大的碰撞体)的方式实现一个Tilemap所有碰撞体合并成一个碰撞体的能力,从而优化碰撞图数量问题:

MergeTilemapCollider2D

TilemapCollider2DMergePreview

可以看到我成功将Tilemap Collider 2D的多个碰撞体合并成了一个碰撞体。

借助Tilemap碰撞体的设计,我们可以专门定义一层Wall的Layer层级,专门用于构建地图边界围墙,然后利用Composite Collider 2D合成一个整体的碰撞体限制地图移动范围:

AddWallIntoTilemap

LimitPlayerMoveRange

可以看到我通过构建边缘碰撞体Tilemap,成功将玩家限制在了指定范围内移动。

摄像机

摄像机渲染

前面我们了解了URP里只有Base和Overlay两类摄像机,要想模拟以前UI摄像机选择Depth Only的效果,我们需要将URP的UI Camera设置成Overlay+Clear Depth的设定,场景摄像机设置成Base+Skybox(或Solid Color)渲染方式。

但仅仅这样设置UI摄像机还不够,还无法正常渲染UI效果。在URP里Overlay摄像机要想渲染还需要添加到Base Camera的Stack里才行:

GameUtilities.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <summary>
/// 查找主摄像机并将UI摄像机添加到其摄像机堆栈
/// </summary>
/// <param name="mainCameraName"></param>
/// <returns></returns>
public static Camera FindMainCameraAndAddUICameraStack(string mainCameraName)
{
var mainCamera = GameObject.Find(mainCameraName)?.GetComponent<Camera>();
if(mainCamera == null)
{
Debug.LogError($"找不到主摄像机:{mainCameraName},添加UICamera Stack失败!");
return null;
}
var uiCamera = CameraManager.Singleton.GetUICamera();
UnityUtilities.AddOverlayCameraToStack(mainCamera, uiCamera);
return mainCamera;
}

UnityUtilities.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/// <summary>
/// 将Overlay摄像机添加到Base摄像机的渲染堆栈中
/// </summary>
/// <param name="baseCamera"></param>
/// <param name="overlayCamera"></param>
/// <returns></returns>
public static bool AddOverlayCameraToStack(Camera baseCamera, Camera overlayCamera)
{
if (baseCamera == null || overlayCamera == null)
{
Debug.LogError("Base camera或者overlay camera为空,添加Overlay摄像机到Base摄像机堆栈失败");
return false;
}

var transitionUACD = baseCamera.GetUniversalAdditionalCameraData();
if(transitionUACD == null)
{
Debug.LogError("Base camera的UniversalAdditionalCameraData为空,添加Overlay摄像机到Base摄像机堆栈失败");
return false;
}
transitionUACD.cameraStack.Add(overlayCamera);
return true;
}

添加到Base摄像机Stack面板:

AddCameraStackInspector

至此主摄像机(Base)+UI摄像机(Overlay)才能正确同时渲染主场景和UI画面:

MainCameraAndUICameraRendering

摄像机跟随

玩家角色在场景里通过键盘或触屏自由移动,这个时候我们的摄像机没法看到完整的地图,所以就需要摄像机跟随角色移动,确保角色在摄像机中间显示从而实现摄像机跟随移动的效果。

而摄像机的功能现成的轮子最强的莫过于官方的Cinemachine,这里我们使用Cinemachine Brain配合Cinemachine Camera+Cinemachine Follow组件实现相机跟随主角保持主角的屏幕中央的效果。

首先在场景摄像机上挂上Cinemachine Brain组件用于支持Cinemachine Camera(好像等价于以前的Virtual Camera)的虚拟摄像机控制。

CinemactionBrainComponentInspector

场景里创建一个挂载Cinemachine Camera的虚拟摄像机节点:

CinemachineCameraFollowLookAtInspector

将Cinemachine Camera的Position Control和Rotation Control设置成Follow和None(不影响摄像机旋转),设置后会自动挂载上Cinemachine Follow和Cinemachine Hard Look At组件。

然后在代码里我们等主角实体对象加载完成后设置Cinemachine Camera的Follow对象为主角实体对象即可。

1
2
3
var playerTransform = mPlayerView.Transform;
mMainCameraView.CinemachineCamera.Follow = playerTransform;
mMainCameraView.CinemachineCamera.LookAt = playerTransform;

但只是设置跟随目标和看向对象会出现主角达到关卡边缘的时候,因为要保持主角在摄像机中心导致摄像机照射到关卡地图无效的区域:

InvalideCameraAreaProblem

要解决摄像机跟随照射无效区域问题看下面的摄像机移动范围限制方案。

摄像机移动范围限制

要想实现Cinemachine的摄像机移动限制,我们可以在Cinemachine Camera所在节点添加CinemachineConfiner2D组件,然后给CinemachineConfiner2D组件指定**专门制作的摄像机范围限制碰撞器(自己编辑的刚好包围Tilemap的Box Collider 2D)**作为移动限定范围,从而实现摄像机移动范围限制到制作的地图城墙范围以内避免看到有效Tilemap地图以外的区域。

CameraAreaBoxCollider2D

我尝试过直接用背景Tilemap的CompositionCollider2D作为摄像机移动范围限制碰状况,但我发现会出现边界不够精确的情况,所以采用的单独制作碰撞体作为摄像机移动范围限制。

1
2
var cameraMoveAreaBoxCollider2D = mTileMapView.CollectCoinMap.CameraMoveAreaBoxCollider2D;
mMainCameraView.SetBoundingShape2D(cameraMoveAreaBoxCollider2D);

设置后可以看到即使主角移动到了地图边缘,摄像机也最多移动到刚好看到地图边界的位置不会出现看到无效地图区域的问题:

InvalideCameraAreaProblemFix

玩法

随机刷道具

有了玩家显示+玩家移动控制+地图显示+摄像机跟随,接下来要实现的就是场景随机刷玩法道具(金币和加时器)的功能,场景时3D照射的正交Tilemap,所以我们的物品渲染要走SpirteRenderer的方式,因为物品要显示在Tilemap之上,我将物品的层级选用了Front(角色在Middle,Tilemap主要使用了Wall(边界城墙),Background(地图背景)和Middle(地图装饰物))。

CoinSpriteRenderInspector

关于游戏开始,游戏结束,物品刷新都属于玩法逻辑,我写在了CCController.cs里:

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
/// <summary>
/// 更新游戏时间
/// </summary>
/// <param name="deltaTime"></param>
public void Update(float deltaTime)
{
if(CollectCoinManager.Singleton.IsWaitLoadGame())
{
return;
}
if(!IsGameStart)
{
if(GameStartTimeLeft >= 0)
{
DecreaseGameStartTime(deltaTime);
}
CheckGameStart();
return;
}
if(!IsGameOver)
{
CheckCoinSpawn(deltaTime);
CheckTimeSpawn(deltaTime);
if(GameTimeLeft >= 0)
{
DecreaseGameTime(deltaTime);
}
CheckGameOver();
return;
}
}

/// <summary>
/// 检查游戏是否可以开始
/// </summary>
private void CheckGameStart()
{
if (!IsGameStart && GameStartTimeLeft <= 0f)
{
Debug.Log("游戏开始");
IsGameStart = true;
CollectCoinManager.Singleton.StartGame();
//CreateObstacleEntities();
}
}

/// <summary>
/// 检查游戏是否结束
/// </summary>
private void CheckGameOver()
{
if (!IsGameOver)
{
if(GameTimeLeft <= 0f)
{
// 游戏结束逻辑
Debug.Log("游戏剩余时间归零,游戏结束");
IsGameOver = true;
CollectCoinManager.Singleton.OverGame();
return;
}
}
}

/// <summary>
/// 检查是否需要生成新的金币
/// </summary>
/// <param name="deltaTime">帧间隔时间</param>
private void CheckCoinSpawn(float deltaTime)
{
if(CollectCoinMap == null)
{
return;
}
mLastCoinSpawnTimePassed += deltaTime;
if(mLastCoinSpawnTimePassed < CollectCoinMap.SpawnCoinInterval)
{
return;
}
var coinNumExit = mWorld.GetEntityNumber(EntityType.CCCoin);
if(coinNumExit >= CollectCoinMap.MaxSpawnCoinNumInMap)
{
return;
}
mLastCoinSpawnTimePassed = 0f;
var randomSpawnPos = GetRandomSpawnPosition();
var coinScore = GetRandomCoinScore();
CreateCoinEntity(randomSpawnPos, coinScore);
}

/// <summary>
/// 检查是否需要生成新的时间道具
/// </summary>
/// <param name="deltaTime">帧间隔时间</param>
private void CheckTimeSpawn(float deltaTime)
{
if(CollectCoinMap == null)
{
return;
}
mLastTimeSpawnTimePassed += deltaTime;
if(mLastTimeSpawnTimePassed < CollectCoinMap.SpawnTimeInterval)
{
return;
}
var timeNumExit = mWorld.GetEntityNumber(EntityType.CCTime);
if(timeNumExit >= CollectCoinMap.MaxSpawnTimeNumInMap)
{
return;
}
mLastTimeSpawnTimePassed = 0f;
var randomSpawnPos = GetRandomSpawnPosition();
var timeAddSeconds = GetRandomTimeAddSeconds();
CreateTimeEntity(randomSpawnPos, timeAddSeconds);
}

为了准确控制场景物品的刷新细节数据逻辑,我将金币和计时器的刷新相关数据配置在了地图Prefab上:

CollectCoinMapInspector

为了增加趣味性,物品的刷新数值(金币数量,加时器加时秒数,刷新物品位置)我都采用了随机的方式:

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
/// <summary>
/// 获取一个随机生成位置
/// </summary>
/// <returns></returns>
private Vector3 GetRandomSpawnPosition()
{
if(CollectCoinMap == null || CollectCoinMap.BackgroundTilemap == null)
{
Debug.LogError("地图数据或背景TileMap未设置,获取随机生成位置失败!");
return Vector3.zero;
}
var cellDataCount = mValideMapCellDatas.Count;
if (cellDataCount <= 0)
{
Debug.LogError("没有可用的地图格子数据,获取随机生成位置失败!");
return Vector3.zero;
}
var backgroundTilemap = CollectCoinMap.BackgroundTilemap;
int index = Random.Range(0, cellDataCount);
Vector3 spawnPosition = backgroundTilemap.GetCellCenterWorld(mValideMapCellDatas[index]);
return spawnPosition;
}

/// <summary>
/// 获取一个随机的金币分数
/// </summary>
/// <returns></returns>
private int GetRandomCoinScore()
{
if(CollectCoinMap == null)
{
Debug.LogError("地图数据未设置,获取随机金币分数失败!");
return 0;
}
return Random.Range(CollectCoinMap.CointMinScoreNum, CollectCoinMap.CointMaxScoreNum);
}

/// <summary>
/// 获取一个随机的时间道具增加秒数
/// </summary>
/// <returns></returns>
private int GetRandomTimeAddSeconds()
{
if(CollectCoinMap == null)
{
Debug.LogError("地图数据未设置,获取随机时间道具增加秒数失败!");
return 0;
}
return Random.Range(CollectCoinMap.TimerMinAddSeconds, CollectCoinMap.TimerMaxAddSeconds);
}

其中关于随机刷新位置我是通过利用背景Tilemap的格子信息+排除中间层有碰撞的装饰物的格子信息来初始化得到的:

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
/// <summary>
/// 初始化有效的地图格子数据
/// </summary>
private void InitValidMapCellDatas()
{
mValideMapCellDatas.Clear();
if(CollectCoinMap == null)
{
Debug.LogError("CollectCoin地图数据不存在,初始化地图格子数据失败。");
return;
}
// 遍历地图格子,收集有效的格子数据
var backgroundTilemap = CollectCoinMap.BackgroundTilemap;
if(backgroundTilemap == null)
{
Debug.LogError("CollectCoin地图的背景Tilemap不存在,初始化地图格子数据失败。");
return;
}
var middleTilemap = CollectCoinMap.MiddleTilemap;
if(middleTilemap == null)
{
Debug.LogError("CollectCoin地图的中间层Tilemap不存在,初始化地图格子数据失败。");
return;
}
var middleCollider = middleTilemap.GetComponent<CompositeCollider2D>();
if (middleCollider == null)
{
Debug.LogError("Middle 层缺少 CompositeCollider2D");
return;
}
// 遍历中间层Tilemap的格子,但要排除Middle层有碰撞体积的单元格子
// 收集有效的格子数据
foreach (Vector3Int cell in backgroundTilemap.cellBounds.allPositionsWithin)
{
if (!backgroundTilemap.HasTile(cell))
{
continue;
}
var worldPosition = backgroundTilemap.CellToWorld(cell);
if(middleCollider.OverlapPoint(worldPosition))
{
continue;
}
mValideMapCellDatas.Add(cell);
}
}

道具刷新效果:

RandomCreateItems

这些逻辑都完成后我们就能看到场景里刷新出对应物品,但此时的物品还没有碰撞后玩家搜集的逻辑,所以还需要给玩家添加碰撞相关逻辑。

玩家碰撞道具搜集

因为游戏玩法主要是根据玩家碰撞进行道具搜集,所以碰撞检测逻辑主要写在玩家身上,碰撞要求两个物体要有碰状况,且至少有一方有Rigidbody组件。

PlayerComponentsInpsector

CoinComponentsInspector

可以看到我给道具添加了Box Collider 2Da并设置了IsTrigger,走触发器的方式,同时玩家身上添加了Rigidbody和Capsule Collider 2D。

我编写了一个MonoBridge.cs的脚本来负责Monobehaviour的统一生命周期(包含碰撞和触发相关接口)转接:

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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
/// <summary>
/// MonoBridge.cs
/// 将 Unity 的 2D 和 3D 碰撞及触发消息同步转发给逻辑层
/// 订阅者负责配对解绑及自身状态检查,对象回池时应调用 ClearCallbacks
/// </summary>
public class MonoBridge : MonoBehaviour
{
/// <summary>
/// 碰撞开始时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision> CollisionEnter;

/// <summary>
/// 碰撞持续时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision> CollisionStay;

/// <summary>
/// 碰撞结束时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision> CollisionExit;

/// <summary>
/// 触发开始时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider> TriggerEnter;

/// <summary>
/// 触发持续时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider> TriggerStay;

/// <summary>
/// 触发结束时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider> TriggerExit;

/// <summary>
/// 碰撞开始时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision2D> CollisionEnter2D;

/// <summary>
/// 碰撞持续时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision2D> CollisionStay2D;

/// <summary>
/// 碰撞结束时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collision2D> CollisionExit2D;

/// <summary>
/// 触发开始时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider2D> TriggerEnter2D;

/// <summary>
/// 触发持续时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider2D> TriggerStay2D;

/// <summary>
/// 触发结束时通知订阅者,使用 += 绑定并使用 -= 解绑
/// </summary>
public event Action<Collider2D> TriggerExit2D;

/// <summary>
/// 对象销毁时释放所有订阅者引用
/// </summary>
private void OnDestroy()
{
ClearCallbacks();
}

/// <summary>
/// 接收 Unity 的碰撞开始消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionEnter(Collision collision)
{
CollisionEnter?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的碰撞持续消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionStay(Collision collision)
{
CollisionStay?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的碰撞结束消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionExit(Collision collision)
{
CollisionExit?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的触发开始消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerEnter(Collider other)
{
TriggerEnter?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的触发持续消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerStay(Collider other)
{
TriggerStay?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的触发结束消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerExit(Collider other)
{
TriggerExit?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的碰撞开始消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionEnter2D(Collision2D collision)
{
Debug.Log($"OnCollisionEnter2D() collision.otherCollider.gameObject.name: {collision.otherCollider.gameObject.name} collision.gameObject.name: {collision.gameObject.name}");
CollisionEnter2D?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的碰撞持续消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionStay2D(Collision2D collision)
{
CollisionStay2D?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的碰撞结束消息并同步转发
/// </summary>
/// <param name="collision">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnCollisionExit2D(Collision2D collision)
{
CollisionExit2D?.Invoke(collision);
}

/// <summary>
/// 接收 Unity 的触发开始消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerEnter2D(Collider2D other)
{
TriggerEnter2D?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的触发持续消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerStay2D(Collider2D other)
{
TriggerStay2D?.Invoke(other);
}

/// <summary>
/// 接收 Unity 的触发结束消息并同步转发
/// </summary>
/// <param name="other">本次物理消息数据,由订阅者在当前回调期间使用</param>
private void OnTriggerExit2D(Collider2D other)
{
TriggerExit2D?.Invoke(other);
}

/// <summary>
/// 清除全部回调,供对象回池或解除整组绑定时使用
/// 单个订阅者停止监听时应自行解绑,避免移除其他模块的订阅
/// </summary>
public void ClearCallbacks()
{
CollisionEnter = null;
CollisionStay = null;
CollisionExit = null;
TriggerEnter = null;
TriggerStay = null;
TriggerExit = null;
CollisionEnter2D = null;
CollisionStay2D = null;
CollisionExit2D = null;
TriggerEnter2D = null;
TriggerStay2D = null;
TriggerExit2D = null;
}
}

逻辑层通过获取这个MonoBridge组件进行对应的接口监听:

CCPlayerCollisionComponent.cs

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
/// <summary>
/// 绑定玩家触发器逻辑的处理逻辑
/// </summary>
private void BindTriggers()
{
if(mIsTriggerBind)
{
return;
}
if(mMonoBridge2DComponent == null)
{
return;
}
mMonoBridge2DComponent.AddTriggerEnter2D(OnPlayerTriggerEnter2D);
mIsTriggerBind = true;
Debug.Log($"玩家触发器逻辑绑定成功。");
}

/// <summary>
/// 玩家与触发器发生交互时的处理逻辑
/// </summary>
/// <param name="collider"></param>
private void OnPlayerTriggerEnter2D(Collider2D collider)
{
var triggerGoName = collider.gameObject.name;
Debug.Log($"玩家与触发器 {triggerGoName} 发生了交互。");
(var result, var entityId, var componentId, var resourceName) = ComponentUtilities.ParseComponentGoName(triggerGoName);
if(!result)
{
return;
}
Debug.Log($"解析到的触发器信息 - EntityId: {entityId}, ComponentId: {componentId}, ResourceName: {resourceName}");
var triggerEnterEntity = World.GetEntity(entityId);
if(triggerEnterEntity == null)
{
Debug.LogError($"未能找到触发器物体对应的实体,EntityId: {entityId}");
return;
}
var triggerEnterEntityType = triggerEnterEntity.EntityType;
if(triggerEnterEntityType == EntityType.CCCoin)
{
OnCoinTriggerEnter(triggerEnterEntity);
}
else if(triggerEnterEntityType == EntityType.CCTime)
{
OnTimeTriggerEnter(triggerEnterEntity);
}
else
{
Debug.LogWarning($"未处理的触发器类型: {triggerEnterEntityType}");
}
}

但物体无论是碰撞器还是触发器,我们通过相关接口能拿到的只有进入碰撞或触发的对象或碰撞组件,那我们如何识别是碰撞的逻辑层面的哪个对象了?

答案是给道具实体对象取名时就携带逻辑数据信息:

ComponentUtilities.cs

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
/// <summary>
/// 获取组件对应的GameObject名称取名
/// </summary>
/// <param name="component">要获取名称的组件</param>
/// <param name="resourceName">组件对应的资源名称</param>
/// <returns></returns>
public static string GetComponentGoName(Component component, string resourceName)
{
if(component == null)
{
Debug.LogError($"不能传空component,获取GameObject名称失败");
return string.Empty;
}
// 返回格式为 "组件Go前缀|EntityType|所属EntityId|组件Id|资源名"
var prefix = EntityFramworkConst.ComponentGoNamePrefix;
var separator = EntityFramworkConst.ComponentGoNameSeparator;
return $"{prefix}{separator}{component.OwnerId.Value}{separator}{component.Id.Value}{separator}{resourceName}";
}

/// <summary>
/// 解析指定GameObject名称,返回所属EntityId、组件Id和资源名信息
/// </summary>
/// <param name="goName"></param>
/// <returns></returns>
public static (bool, EntityId, ComponentId, string) ParseComponentGoName(string goName)
{
var separator = EntityFramworkConst.ComponentGoNameSeparator;
var parts = goName.Split(separator);
if(parts.Length != 4)
{
return (false, default, default, string.Empty);
}
var prefix = parts[0];
if(!string.Equals(prefix, EntityFramworkConst.ComponentGoNamePrefix))
{
return (false, default, default, string.Empty);
}
if(!ulong.TryParse(parts[1], out var entityId))
{
Debug.LogError($"无效的EntityId: {parts[1]},解析组件Go名字失败!");
return (false, default, default, string.Empty);
}
if(!ulong.TryParse(parts[2], out var componentId))
{
Debug.LogError($"无效的ComponentId: {parts[2]},解析组件Go名字失败!");
return (false, default, default, string.Empty);
}
var resourceName = parts[3];
var realEntityId = new EntityId(entityId);
var realComponentId = new ComponentId(componentId);
return (true, realEntityId, realComponentId, resourceName);
}

EntityFrameworkConst.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/// <summary>
/// EntityFramworkConst.cs
/// 实体框架相关的常量定义
/// </summary>
public static class EntityFramworkConst
{
/// <summary>
/// 组件对应的GameObject名称的前缀
/// </summary>
public const string ComponentGoNamePrefix = "EF";

/// <summary>
/// 组件对应的GameObject名称中的分隔符
/// </summary>
public const char ComponentGoNameSeparator = '|';
}

可以看到我们在给实体对象取名时就定义了一下规则:

组件Go前缀|EntityType|所属EntityId|组件Id|资源名

所以当有物体进入碰撞时我按对应方式解析,如果不符合解析规则则说明不是我们游戏逻辑创建的对象不符合碰撞处理,符合解析规则则解析出我们需要的逻辑对象数据,从而知道碰撞的道具是什么。

道具信息HUD

有上述道具刷新和收集逻辑,核心玩法有了,但有一点很不方便的是,我们金币和加时器是随机刷新的数值,但从游戏画面缺看不出来到底具体数值是多少,这个时候我们需要给场景物品加上额外的信息展示,而我采用的是用UGUI绘制HUD数据的方式展示。

UGUI绘制HUD信息展示的核心是利用3D物体挂点结合摄像机进行3D坐标转UI坐标的转换,从而实现HUD的挂点跟随效果。

首先给道具预制件制作了HUD挂点:

CoinMonoHudPointInspector

然后是抽象HUD跟随组件功能:

HUDFollowComponent.cs

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
/// <summary>
/// HUDFollowComponent.cs
/// 负责HUD跟随逻辑的组件
/// </summary>
public class HUDFollowComponent : EntityFramework.Component, IInitializable<HUDFollowComponentData>
{
/// <summary>
/// HUD跟随目标
/// </summary>
[EntityFrameworkDebugDisplay(Order = 101)]
public Transform FollowTarget
{
get;
private set;
}

/// <summary>
/// 是否为静态目标
/// </summary>
[EntityFrameworkDebugDisplay(Order = 102)]
public bool IsStaticTarget
{
get;
private set;
}

/// <summary>
/// HUD的Transform组件
/// </summary>
[EntityFrameworkDebugDisplay(Order = 103)]
public Transform HudTransform
{
get;
private set;
}

/// <summary>
/// HUD的RectTransform组件
/// </summary>
private RectTransform mHudRectTransform;

/// <summary>
/// 主摄像机引用
/// </summary>
private Camera mMainCamera;

/// <summary>
/// UI摄像机引用
/// </summary>
private Camera mUICamera;

/// <summary>
/// 初始化HUDFollowComponent组件
/// </summary>
/// <param name="data"></param>
public void Initialize(HUDFollowComponentData data)
{
FollowTarget = data?.FollowTarget;
IsStaticTarget = data?.IsStaticTarget ?? false;
mMainCamera = CameraManager.Singleton.GetMainCamera();
mUICamera = CameraManager.Singleton.GetUICamera();
UpdateHUDPos();
}

/// <summary>
/// 绑定HUD跟随的目标及其静态属性
/// </summary>
/// <param name="followTarget"></param>
/// <param name="isStaticTarget"></param>
public void BindFollowTarget(Transform followTarget, bool isStaticTarget = false)
{
FollowTarget = followTarget;
IsStaticTarget = isStaticTarget;
UpdateHUDPos();
}

/// <summary>
/// 绑定HUD的Transform组件
/// </summary>
/// <param name="hudTransform">HUD的Transform组件</param>
public void BindHudTransform(Transform hudTransform)
{
HudTransform = hudTransform;
var hudParent = HudTransform?.parent;
mHudRectTransform = hudParent as RectTransform;
UpdateHUDPos();
}

/// <summary>
/// 每帧更新HUD的位置,使其跟随目标
/// </summary>
/// <param name="deltaTime"></param>
protected override void OnLateUpdate(float deltaTime)
{
base.OnLateUpdate(deltaTime);
if(IsStaticTarget)
{
return;
}
UpdateHUDPos();
}

/// <summary>
/// 更新HUD的位置,使其跟随目标
/// </summary>
private void UpdateHUDPos()
{
if(FollowTarget == null || HudTransform == null ||
mMainCamera == null || mUICamera == null ||
mHudRectTransform == null)
{
return;
}
var followTargetWorldPos = FollowTarget.position;
var result = UnityUtilities.WorldToRectWorldPos(mMainCamera, followTargetWorldPos, mUICamera, mHudRectTransform, out var uiWorldPosition);
if(result)
{
HudTransform.position = uiWorldPosition;
//Debug.Log($"更新Owner EntityId:{OwnerId}的HUDFollowComponent的HudTransform位置:{uiWorldPosition}");
}
}
}

UnityUtilities.cs

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
/// <summary>
/// UnityUtilities.cs
/// Unity工具类,提供一些常用的Unity相关辅助方法
/// </summary>
public static class UnityUtilities
{
/// <summary>
/// 指定世界摄像机和3D坐标转换到指定UI摄像机和2D RectTransform下的世界坐标
/// </summary>
/// <param name="worldCamera">渲染目标的游戏相机</param>
/// <param name="worldPosition">目标在场景中的世界坐标</param>
/// <param name="uiCamera">用于渲染UI的相机</param>
/// <param name="rect">目标在HUD中的RectTransform</param>
/// <param name="uiWorldPosition">输出的UI世界坐标</param>
/// <returns>将指定世界坐标转换到UI世界坐标,转换成功返回true,失败返回false</returns>
public static bool WorldToRectWorldPos(Camera worldCamera, Vector3 worldPosition,
Camera uiCamera, RectTransform rect,
out Vector3 uiWorldPosition)
{
uiWorldPosition = Vector3.zero;
if (worldCamera == null || rect == null)
{
Debug.LogError("Camera或者RectTransform为空,坐标转换失败");
return false;
}
Vector3 screenPosition = worldCamera.WorldToScreenPoint(worldPosition);
var screenToRectResult = RectTransformUtility.ScreenPointToWorldPointInRectangle(rect, screenPosition, uiCamera,
out uiWorldPosition);
if(!screenToRectResult)
{
Debug.LogError("屏幕坐标转换到RectTransform失败");
return false;
}
return true;
}
}

关于跟随显示有一个要注意的点,因为摄像机Cinemachine Brain使用的是Update Method为Smart Update和Blend Update Method为Late Update:

CinemachineBrainUpdateSetting

要想3D算2D位置不出现晃动,这个3D计算必须在摄像机计算移动之后,像目前我这里就是必须在Late Update之后,游戏逻辑的Update是由GameLauncher.cs脚本驱动过来的,也就是GameLauncher.cs和Cinemachine Brain的更新顺序,这里我们可以通过Project Settings->Script Execution Order进行自定义的脚本执行顺序配置:

GameLuancherScriptExecutionOrder

然后就是制作一个HUD的挂载UI窗口:

WindowCollectCoinHudUI

通过将HUD挂载到UI窗口节点上,然后通过3D算2D计算位置后,最终HUD效果:

HudFinalResult

可以看到我们成功将金币的随机数值信息通过HUD的方式跟随显示出来了。

音乐和音效

操作,玩法,摄像机跟随都实现了,游戏还是看起来没那么激情,这个时候我们需要将背景音乐和对应玩法时机的音效加上才有感觉。

  1. 进入游戏就播放背景音乐
  2. 吃到金币和加时道具播放收集音效
  3. 玩家移动播放行走音效。

这个代码没什么好说的,主要就是在对应时机播放对应背景音乐和音效,除了玩家移动播放行走音效需要自行逻辑处理间隔播放控制,因为玩家移动操作是每帧都在同步的。

1
2
3
4
5
6
7
8
9
10
11
12
13
/// <summary>
/// 检测是否需要播放玩家行走声音
/// </summary>
private void CheckPlayPlayerWalkSound()
{
var newRealtimeSinceStartup = Time.realtimeSinceStartup;
if(newRealtimeSinceStartup - mLastPlayPlayerWalkSoundTime < CollectCoinConst.PlayWalkSoundInterval)
{
return;
}
mLastPlayPlayerWalkSoundTime = Time.realtimeSinceStartup;
AudioManager.Singleton.PlaySFXSound("player_walk.wav");
}

Reference

2D Adventure Game: Robot Repair

文章目录
  1. 1. 前言
  2. 2. 创建项目
  3. 3. URP
    1. 3.1. 要求和兼容
      1. 3.1.1. Render pipeline compatibility
      2. 3.1.2. Graphics API Compatibility
      3. 3.1.3. System Requirements
    2. 3.2. URP Rendering
      1. 3.2.1. URP Renderer
      2. 3.2.2. Shading models in URP
      3. 3.2.3. Camera
      4. 3.2.4. URP Asset
  4. 4. 输入系统
  5. 5. 正交摄像机Size选择
  6. 6. Sprite Flight小游戏
    1. 6.1. 自适应边界
  7. 7. 2D搜集金币小游戏
    1. 7.1. 创建角色
      1. 7.1.1. 导入Spine资源
      2. 7.1.2. 加载Spine并播放动画
      3. 7.1.3. 控制Spine移动
      4. 7.1.4. Spine添加碰撞体
    2. 7.2. 制作场景
      1. 7.2.1. Tilemap
        1. 7.2.1.1. 安装Tilemap
        2. 7.2.1.2. 创建Tilemap
        3. 7.2.1.3. Tile Palette
        4. 7.2.1.4. Using Tile Asset
        5. 7.2.1.5. Tilemap渲染顺序
        6. 7.2.1.6. Tilemap碰撞
    3. 7.3. 摄像机
      1. 7.3.1. 摄像机渲染
      2. 7.3.2. 摄像机跟随
      3. 7.3.3. 摄像机移动范围限制
    4. 7.4. 玩法
      1. 7.4.1. 随机刷道具
      2. 7.4.2. 玩家碰撞道具搜集
      3. 7.4.3. 道具信息HUD
    5. 7.5. 音乐和音效
  8. 8. Reference