前言 本章节是深入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渲染场景通过以下组件:
URP Renderer
Shaing models for shaders shiiped with URP
Camera
URP asset
URP渲染流程预览:
如果想将非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包含以下两个方式:
Universal Renderer
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:
Physically Based Shading
Simple Shading
Baked Lit Shading
No lighting
Camera URP里的Camera的Render Type有两类:
Base
general purpose Camera that renders to a render target (a screen, or a Render Texture)
个人理解对应原来Camera的非overlay方式,就是之前最常见的场景摄像机方式
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选项设置:
因为URP的Base Camera只有Skybox和Solid Color选项,要模拟以前UI摄像机选择Depth Only的效果,我们需要将URP的Camera设置成Overlay+Clear Depth的设定:
但仅仅这样设置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里:
至此终于能看到UI画面渲染了:
这里遇到一个问题,Overlay Camera借助Base Camera Stack渲染叠加,但没法像以前一样选中对应摄像机进行Scene场景然选画面预览了,有知道如何解决的请告知一声。
Note:
URP摄像机必须要有UniversalAdditionalCameraData组件
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:
这里简单看下Universal Render Pipeline Asset的Inspector面板参数:
可以看到URP Asset里指定了一个Renderer2D(Renderer 2D Data)的Asset作为渲染器数据(第0个是默认选择 ):
也就是说将使用Renderer 2D作为摄像机的默认URP渲染。
URP摄像机是否使用这个Renderer 2D作为渲染器是在Camera的Renderer上设置的:
简单看下Renderer 2D Data的Inspector面板:
输入系统 老版Unity用的是老的输入系统,我们会给EventSystem组件挂载StandloneInputModule组件,而新Input System需要挂在InputSystemUIInputModule组件:
老版输入系统直接访问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 private InputAction mClickAction;private void Awake (){ mClickAction = new InputAction(); mClickAction.AddBinding("<Mouse>/leftButton" ); mClickAction.AddBinding("<Touchscreen>/primaryTouch/press" ); mClickAction.performed += HandleClick; } private void OnDestroy (){ mClickAction.performed -= HandleClick; mClickAction.Dispose(); } 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 ; } 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 private const float MinimumDistance = 1f ;private bool mIsTracking;private int mFirstTouchId;private int mSecondTouchId;private float mPreviousDistance;public event Action<float , Vector2> ZoomRequested;private void OnEnable (){ EnhancedTouchSupport.Enable(); mIsTracking = false ; } 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); } private void OnDisable (){ mIsTracking = false ; EnhancedTouchSupport.Disable(); } 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 ; } } 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 ; } private bool IsPressed (EnhancedTouch touch ){ return touch.phase == InputTouchPhase.Began || touch.phase == InputTouchPhase.Moved || touch.phase == InputTouchPhase.Stationary; }
新版Input System支持了inputactions的配置文件:
具体的配置面板:
比如前面提到的InputAction监听按钮左键点击和屏幕点击的AddBinding路径信息在这里就能查看到:
正交摄像机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)去阻碍和限制运动范围。
自适应边界
可以看到我们制作了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实际效果:
分辨率960*640实际效果:
可以看到正如我们前面计算的公式一样,摄像机正交Size个每条边的位置正如预期的值一致。
2D搜集金币小游戏 参考教程:
2D Adventure Game: Robot Repair
教程只是参考学习,实际并没有完全按照教程流程走,我个人以制作一个2D搜集金币的小游戏作为学习Demo,这里我个人没用SpriteRenderer+序列帧的方式,我直接使用Spine作为2D角色。
创建角色 Spine相关知识学习参考:
Spine学习
导入Spine资源 这里直接使用Spine包里的Demo官方的Spineboy作为Spine资源,用Spineboy资源制作角色Prefab。
Note:
导入Spine资源之前请先安装Spine包
加载Spine并播放动画 资源加载每个项目有自己的资源加载框架这里就不展示了,这里只要讲讲Spine的动画播放,这里我使用的SkeletonAnimation组件进行Spine动画播放控制:
1 SkelectonAnimation.AnimationState.SetAnimation(0 , mPlayAnimName, mIsLoopAnim);
控制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 private InputAction mKeyboardMoveAction;private InputAction mTouchMoveAction;private InputAction mClickAction;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 ); 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 public class CCPlayerControlController : BaseController { ****** 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); } public void Update (float deltaTime ) { } private void OnInputMoveStart (EventParam eventParam ) { if (!CollectCoinManager.Singleton.IsInGame()) { return ; } Debug.Log($"CCPlayerControlController响应输入移动开始事件触发" ); UpdatePlayerStartMove(); } 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); } private void CancelInputMove (EventParam eventParam ) { Debug.Log($"CCPlayerControlController响应取消输入移动事件触发" ); StopPlayerMove(); } 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并设置好合适的大小位置。
后续还会给地图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安装对应包:
创建Tilemap 相关文档:
Tilemaps
参考教程:
Introduction to Tilemaps
Tilemap由两部分组成:
Grid
相关参数介绍:
Cell Size : 单个Square网格的大小配置
Cell Gap : Square网格之间的间隔
Cell Layout : 地块tiles的排版格式
Cell Swizzle : 网格的朝向配置
Tilemap
Tilemap组件
相关参数介绍:
Animation Frame Rate : 地块Tile的动画帧率
Color : 配置地块Tiles颜色
Tile Anchor : 配置地块Tile的锚点
Orientation : 地块Tile的朝向
Timemap Renderer组件
相关参数介绍:
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:
Grid和Tilemap是1对多关系,只允许场景里有1个Grid,但可以有多个Tilemap
Tile Palette Tilemap里我们通过将Sprite制作成一个一个的Tile来编辑绘制地图,而Tile Palette(地块调色板)就是我们最常用到的工具。
打开Tile Pallette:
Window->2D->Tile Palette
使用Tile Pallette创建用于我们自己游戏的Tile Pallette,我们点击激活的Tile Pallette下拉框后选择Create New Tile Pallette:
创建成功后会发现目前还没有任何的Tile内容:
在编辑添加Tile Asset之前,我们选中刚创建的Palette进行基础的Palette设置:
这里我想自定义Cell Size大小所哟Cell Sizing我设置成了Manual,同时我是正交照射2D显示所以配置了Sort Mode为Orthographic
然后选中Palette Prefab设置Grid的Cell Size到1,1,1:
接下来我将免费资源:
Adventure Game: Robot Repair | 2D Sample Project 里的一部分资源用来作为制作Tile Palette的资源
添加Sprite作为Tile(直接拖动Sprite到Tile Palette里):
可以看到一张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的导入:
Note:
自定义Tile Asset Create通过继承TileTemplate脚本进行编写(这个要走Assets->2D->Tile Palette->New Tile Set创建后方可设置)
Using Tile Asset 在前面我们创建好Tile Palette和对应的Tile Asset后,接下来我们就能通过Tile Map进行绘制我们的自定义地图了。
首先我们要单独创建一个Tilemap预制件:
设置成我们想要的Cell Size大小:
然后打开Tile Palette选中我们刚才创建的Tile Palette作为素材Palette:
然后激活Tilemap的绘笔:
选中要绘制的Tile Asset在Scene里左键绘制:
绘制地图完成:
要想编辑好的Tilemap显示的Spine人物模型之后,我们需要设置Tilemap Renderer组件的Sorting Layer和Order:
然后把Tilemap编辑好的预制件和Spine模型预制件加载出来显示:
可以看到因为我把角色的Layer调整的比Tilemap高,所以角色显示在了上层。
但这样一来角色会凌驾于所以场景物体之上:
Tilemap渲染顺序 像前面说到的Tile渲染层级问题,我们需要制作Tilemap时就规划好层级。
首先我们要拆分出Tilemap的背景层,中间层和顶层层级设计
Background用于Tilemap背景,Middle用于中间层所有物体显示,Front用于显示在顶层的物体。
重新制作Tilemap,将背景,场景物体和顶层物体归类,分别创建3个Tilemap进行编辑:
将角色的Renderer Layer修改到Middle层(和场景物件显示在同一层):
此时我们会发现,虽然场景物件和角色在同一层Layer渲染了,但玩家角色和场景物件没法根据玩家所在位置(Y值)决定是显示在场景物件前面还是后面:
为了解决这个问题,我们需要修改Camera渲染层级的依据方式,因为我创建的Universal 2D Template,摄像机默认用的Renderer2D Asset的渲染方式,我们需要将Renderer2D Asset的Transparency Sort Mode从Default(根据深度排序)修改成Custom Axis+Transparency Sort Axis(0,1,0) :
上述设置表示我们的摄像机渲染顺序根据Y轴坐标比较来判定渲染先后顺序(同Layer同Order的情况下),最后我们就会得到角色如果在物件下方就渲染整个,角色在物件上方就只渲染未被遮挡部分:
但是部分跟角色同Layer的Tile我发现遮挡层级并不完全正确:
这是因为我们物品Sprite的Pivot默认指定的Center:
摄像机根据Y比较渲染顺序是根据Tile所在位置的Y值来比较的,而Y值的值是根据Tile的Pivot所在位置的Y值来判定的。
修复后会发现Tile网上移动了一点,应该因为Pivot修改了影响了位置,但是和玩家的层级问题表现已经正确了:
Note:
Tile的Sprite Pivot除了影响位置判定点,还影响旋转,旋转是围绕Pivot转的
Tilemap碰撞 Tilemap层级渲染正确了,接下来我们如何给Tilemap的Tile添加碰撞,从而实现Tile的阻挡,交互。
要给绘制好的Tilemap的Tile元素添加碰撞组件,我们需要给挂载Tilemap组件节点添加Tilemap Collisder 2D组件:
可以看到我给MiddleTilemap添加了Tilemap Collider 2D组件后,对应创建的Tile就自动构建了碰撞体。
运行游戏就会发现因为添加了碰撞体,场景物体就能阻挡角色移动穿透了:
Tilemap Collider 2D的碰撞体构成类型默认是Composite Operation为None(每个Tile独立一个碰撞体) ,如果场景带碰撞体的物体多了,碰撞体会成为性能瓶颈。
优化Tilemap碰撞体问题我们可以使用Composition Collider 2D组件加Tilemap Collider 2D的Composite Operation为Merge(合并成一个大的碰撞体)的方式实现一个Tilemap所有碰撞体合并成一个碰撞体的能力,从而优化碰撞图数量问题:
可以看到我成功将Tilemap Collider 2D的多个碰撞体合并成了一个碰撞体。
借助Tilemap碰撞体的设计,我们可以专门定义一层Wall的Layer层级,专门用于构建地图边界围墙,然后利用Composite Collider 2D合成一个整体的碰撞体限制地图移动范围:
可以看到我通过构建边缘碰撞体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 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 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面板:
至此主摄像机(Base)+UI摄像机(Overlay)才能正确同时渲染主场景和UI画面:
摄像机跟随 玩家角色在场景里通过键盘或触屏自由移动,这个时候我们的摄像机没法看到完整的地图,所以就需要摄像机跟随角色移动,确保角色在摄像机中间显示从而实现摄像机跟随移动的效果。
而摄像机的功能现成的轮子最强的莫过于官方的Cinemachine,这里我们使用Cinemachine Brain配合Cinemachine Camera+Cinemachine Follow组件实现相机跟随主角保持主角的屏幕中央的效果。
首先在场景摄像机上挂上Cinemachine Brain组件用于支持Cinemachine Camera(好像等价于以前的Virtual Camera)的虚拟摄像机控制。
场景里创建一个挂载Cinemachine Camera的虚拟摄像机节点:
将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;
但只是设置跟随目标和看向对象会出现主角达到关卡边缘的时候,因为要保持主角在摄像机中心导致摄像机照射到关卡地图无效的区域:
要解决摄像机跟随照射无效区域问题看下面的摄像机移动范围限制方案。
摄像机移动范围限制 要想实现Cinemachine的摄像机移动限制,我们可以在Cinemachine Camera所在节点添加CinemachineConfiner2D组件,然后给CinemachineConfiner2D组件指定**专门制作的摄像机范围限制碰撞器(自己编辑的刚好包围Tilemap的Box Collider 2D)**作为移动限定范围,从而实现摄像机移动范围限制到制作的地图城墙范围以内避免看到有效Tilemap地图以外的区域。
我尝试过直接用背景Tilemap的CompositionCollider2D作为摄像机移动范围限制碰状况,但我发现会出现边界不够精确的情况,所以采用的单独制作碰撞体作为摄像机移动范围限制。
1 2 var cameraMoveAreaBoxCollider2D = mTileMapView.CollectCoinMap.CameraMoveAreaBoxCollider2D;mMainCameraView.SetBoundingShape2D(cameraMoveAreaBoxCollider2D);
设置后可以看到即使主角移动到了地图边缘,摄像机也最多移动到刚好看到地图边界的位置不会出现看到无效地图区域的问题:
玩法 随机刷道具 有了玩家显示+玩家移动控制+地图显示+摄像机跟随,接下来要实现的就是场景随机刷玩法道具(金币和加时器)的功能,场景时3D照射的正交Tilemap,所以我们的物品渲染要走SpirteRenderer的方式,因为物品要显示在Tilemap之上,我将物品的层级选用了Front(角色在Middle,Tilemap主要使用了Wall(边界城墙),Background(地图背景)和Middle(地图装饰物))。
关于游戏开始,游戏结束,物品刷新都属于玩法逻辑,我写在了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 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 ; } } private void CheckGameStart (){ if (!IsGameStart && GameStartTimeLeft <= 0f ) { Debug.Log("游戏开始" ); IsGameStart = true ; CollectCoinManager.Singleton.StartGame(); } } private void CheckGameOver (){ if (!IsGameOver) { if (GameTimeLeft <= 0f ) { Debug.Log("游戏剩余时间归零,游戏结束" ); IsGameOver = true ; CollectCoinManager.Singleton.OverGame(); return ; } } } 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); } 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上:
为了增加趣味性,物品的刷新数值(金币数量,加时器加时秒数,刷新物品位置)我都采用了随机的方式:
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 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; } private int GetRandomCoinScore (){ if (CollectCoinMap == null ) { Debug.LogError("地图数据未设置,获取随机金币分数失败!" ); return 0 ; } return Random.Range(CollectCoinMap.CointMinScoreNum, CollectCoinMap.CointMaxScoreNum); } 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 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 ; } 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); } }
道具刷新效果:
这些逻辑都完成后我们就能看到场景里刷新出对应物品,但此时的物品还没有碰撞后玩家搜集的逻辑,所以还需要给玩家添加碰撞相关逻辑。
玩家碰撞道具搜集 因为游戏玩法主要是根据玩家碰撞进行道具搜集,所以碰撞检测逻辑主要写在玩家身上,碰撞要求两个物体要有碰状况,且至少有一方有Rigidbody组件 。
可以看到我给道具添加了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 public class MonoBridge : MonoBehaviour { public event Action<Collision> CollisionEnter; public event Action<Collision> CollisionStay; public event Action<Collision> CollisionExit; public event Action<Collider> TriggerEnter; public event Action<Collider> TriggerStay; public event Action<Collider> TriggerExit; public event Action<Collision2D> CollisionEnter2D; public event Action<Collision2D> CollisionStay2D; public event Action<Collision2D> CollisionExit2D; public event Action<Collider2D> TriggerEnter2D; public event Action<Collider2D> TriggerStay2D; public event Action<Collider2D> TriggerExit2D; private void OnDestroy () { ClearCallbacks(); } private void OnCollisionEnter (Collision collision ) { CollisionEnter?.Invoke(collision); } private void OnCollisionStay (Collision collision ) { CollisionStay?.Invoke(collision); } private void OnCollisionExit (Collision collision ) { CollisionExit?.Invoke(collision); } private void OnTriggerEnter (Collider other ) { TriggerEnter?.Invoke(other); } private void OnTriggerStay (Collider other ) { TriggerStay?.Invoke(other); } private void OnTriggerExit (Collider other ) { TriggerExit?.Invoke(other); } 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); } private void OnCollisionStay2D (Collision2D collision ) { CollisionStay2D?.Invoke(collision); } private void OnCollisionExit2D (Collision2D collision ) { CollisionExit2D?.Invoke(collision); } private void OnTriggerEnter2D (Collider2D other ) { TriggerEnter2D?.Invoke(other); } private void OnTriggerStay2D (Collider2D other ) { TriggerStay2D?.Invoke(other); } private void OnTriggerExit2D (Collider2D other ) { TriggerExit2D?.Invoke(other); } 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 private void BindTriggers (){ if (mIsTriggerBind) { return ; } if (mMonoBridge2DComponent == null ) { return ; } mMonoBridge2DComponent.AddTriggerEnter2D(OnPlayerTriggerEnter2D); mIsTriggerBind = true ; Debug.Log($"玩家触发器逻辑绑定成功。" ); } 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 public static string GetComponentGoName (Component component, string resourceName ){ if (component == null ) { Debug.LogError($"不能传空component,获取GameObject名称失败" ); return string .Empty; } var prefix = EntityFramworkConst.ComponentGoNamePrefix; var separator = EntityFramworkConst.ComponentGoNameSeparator; return $"{prefix} {separator} {component.OwnerId.Value} {separator} {component.Id.Value} {separator} {resourceName} " ; } 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 public static class EntityFramworkConst { public const string ComponentGoNamePrefix = "EF" ; public const char ComponentGoNameSeparator = '|' ; }
可以看到我们在给实体对象取名时就定义了一下规则:
组件Go前缀|EntityType|所属EntityId|组件Id|资源名
所以当有物体进入碰撞时我按对应方式解析,如果不符合解析规则则说明不是我们游戏逻辑创建的对象不符合碰撞处理,符合解析规则则解析出我们需要的逻辑对象数据,从而知道碰撞的道具是什么。
道具信息HUD 有上述道具刷新和收集逻辑,核心玩法有了,但有一点很不方便的是,我们金币和加时器是随机刷新的数值,但从游戏画面缺看不出来到底具体数值是多少,这个时候我们需要给场景物品加上额外的信息展示,而我采用的是用UGUI绘制HUD数据的方式展示。
UGUI绘制HUD信息展示的核心是利用3D物体挂点结合摄像机进行3D坐标转UI坐标的转换,从而实现HUD的挂点跟随效果。
首先给道具预制件制作了HUD挂点:
然后是抽象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 public class HUDFollowComponent : EntityFramework.Component , IInitializable <HUDFollowComponentData >{ [EntityFrameworkDebugDisplay(Order = 101) ] public Transform FollowTarget { get ; private set ; } [EntityFrameworkDebugDisplay(Order = 102) ] public bool IsStaticTarget { get ; private set ; } [EntityFrameworkDebugDisplay(Order = 103) ] public Transform HudTransform { get ; private set ; } private RectTransform mHudRectTransform; private Camera mMainCamera; private Camera mUICamera; public void Initialize (HUDFollowComponentData data ) { FollowTarget = data?.FollowTarget; IsStaticTarget = data?.IsStaticTarget ?? false ; mMainCamera = CameraManager.Singleton.GetMainCamera(); mUICamera = CameraManager.Singleton.GetUICamera(); UpdateHUDPos(); } public void BindFollowTarget (Transform followTarget, bool isStaticTarget = false ) { FollowTarget = followTarget; IsStaticTarget = isStaticTarget; UpdateHUDPos(); } public void BindHudTransform (Transform hudTransform ) { HudTransform = hudTransform; var hudParent = HudTransform?.parent; mHudRectTransform = hudParent as RectTransform; UpdateHUDPos(); } protected override void OnLateUpdate (float deltaTime ) { base .OnLateUpdate(deltaTime); if (IsStaticTarget) { return ; } UpdateHUDPos(); } 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; } } }
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 public static class UnityUtilities { 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:
要想3D算2D位置不出现晃动,这个3D计算必须在摄像机计算移动之后 ,像目前我这里就是必须在Late Update之后,游戏逻辑的Update是由GameLauncher.cs脚本驱动过来的,也就是GameLauncher.cs和Cinemachine Brain的更新顺序,这里我们可以通过Project Settings->Script Execution Order进行自定义的脚本执行顺序配置:
然后就是制作一个HUD的挂载UI窗口:
通过将HUD挂载到UI窗口节点上,然后通过3D算2D计算位置后,最终HUD效果:
可以看到我们成功将金币的随机数值信息通过HUD的方式跟随显示出来了。
音乐和音效 操作,玩法,摄像机跟随都实现了,游戏还是看起来没那么激情,这个时候我们需要将背景音乐和对应玩法时机的音效加上才有感觉。
进入游戏就播放背景音乐
吃到金币和加时道具播放收集音效
玩家移动播放行走音效。
这个代码没什么好说的,主要就是在对应时机播放对应背景音乐和音效,除了玩家移动播放行走音效需要自行逻辑处理间隔播放控制,因为玩家移动操作是每帧都在同步的。
1 2 3 4 5 6 7 8 9 10 11 12 13 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