Hi,我是李大数,喜欢探究数学,美术,游戏,心理等一切有艺术感之物,并把所得、所知、所感和朋友们一起分享的李大数。
Unity官方出品的《Ruby的大冒险》2D游戏辅导课确实是不可多得的很棒的入门课程,作者不是像很多“傲娇”的开发者一样寥寥数语讲几个要点,而是像一位耐心和蔼的老师一般,一步一步由浅入深的讲解每一个具体的操作和背后的原理,最终完成一个非常完整的具备2D游戏所有开发要素的游戏。可惜只提供了英文版本,中文读者难免在学习过程中产生种种误读而踩坑,因此我计划逐课进行解读,加入自己的一些补充性总结和实际操作经验。
各位读者如果发现错误或者有任何建议,可在评论中提出,并说明版本号,我会持续修订此文。
EN | CN |
---|---|
Asset | 资产 |
Animator | 动画器 |
AnimationClip | 动画剪辑 |
Prefab | 预制件 |
Sprite | 精灵 |
GameObject | 游戏对象 |
Tilemap | 瓦片地图 |
Rigidbody | 刚体 |
Collider | 碰撞体 |
Trigger | 触发器 |
Anchor | 锚点 |
Pivot | 支点 |
KISS | Keep It Simple and Stupid |
Audio | 音频 |
Audio Clip | 音频剪辑 |
Texture | 纹理 |
Script | 脚本 |
PPU | Pixel per Unit,每单元像素 |
官方教程
原文链接
欢迎来到Ruby的大冒险:2D游戏初学者项目!
在这个项目中,你不仅能学会如何创建游戏——每节辅导课都会解释每步操作背后的逻辑规则。不需要先具备什么经验,所以这个项目非常适合于开始你的Unity之旅。
在这第一节课中,你将探索Unity编辑器,并学会为你的游戏导入Assets资产。
在下载本节课的项目之前,你首先需要做的是:
关于使用Unity Hub,参见Unity Hub 文档
如果你没有最新的官方release版本,从Unity Hub窗口左侧的菜单中选择Install 一栏来下载它。
注意: 本项目的学习内容仅兼容于2018.3以及之后的官方release版本。
打开Unity Hub。
在可用项目列表中向下滚动找到此项目并选中。
在顶部菜单中,选择 File > Save来保存你的项目版本。你也可以使用Ctrl + S (Windows) 或者 Cmd + S (macOS)快捷键来保存。
命名并保存你的场景。
注意:一些用户会碰到无法保存所下载的项目的问题。如果你也遇到这个问题,你可以尝试以下办法:
在顶部菜单中,选择 File > Close。
使用你的操作系统的保存窗口保存你的项目。为你的项目选一个容易访问的位置,例如一个专门的项目目录或者你的文档目录。
当项目被保存时,重新打开Unity Hub。
在项目列表中左键单击项目来打开它,继续我们的课程。
完成上述过程后你将可以正常保存项目了。
在你探索Unity编辑器之前,让我们先得到你项目中需要的资产文件。你可能已经通过上面的第二步搞定了,不过你仍可以利用Unity资产商店。
所有的文件都在Unity资产商店里,该商店让资产创作者能够提供工具或者文件给其他Unity用户。
然后资产商店就在你的编辑器内打开了。
现在你已经设置完成,让我们开始探索编辑器是如何工作的!
让我们首先概览编辑器的窗口。别担心,你无需记住每个细节!
这里仅仅给你一个哪个窗口做什么的宽泛概念,这样当你探索编辑器更多细节的时候,你就知道该去看屏幕上的哪一部分了。
Project window(项目窗口)
视频
项目窗口列出了你当前项目中所有的文件和目录。这些文件包括所有你项目中用到的图片,声音和3D模型。他们以Assets(资产)的形式被集合于此。
Console windows(控制台窗口)
视频
让我们看看控制台窗口。缺省布局下,控制台窗口的tab栏紧贴项目窗口的tab栏。
控制台窗口显示了游戏给出的警告和错误,为修正这些问题提供有用的信息。
你可以通过拖放控制台窗口的tab栏,把它停靠在项目窗口的旁边。
Hierarchy(层级窗口)
在Unity中,你的游戏由场景组成。可以认为场景是一个平面或是一个不同的环境。每个场景内有一个对象列表,这些对象在Unity内被叫做GameObjects。
你可以放置GameObject(游戏对象)到一个父子关系的层次结构中。游戏对象可以是其他游戏对象的孩子,这让你可以成组移动它们(意思是如果一个父对象移动了,该父对象的所有孩子对象也会跟着移动)。层级窗口在你的场景中以父子关系展示了所有的游戏对象。
视频
Scene view(场景视图)
视频
场景视图是一个实时预览窗口,它可以实时预览当前载入的场景及其层级结构中的所有游戏对象。你可以用它在场景中放置和移动游戏对象。在场景视图中点击游戏对象可以让它在层级窗口中高亮显示。
Game view(游戏视图)
游戏视图是当你在编辑器中测试游戏时显示的一个视图。
视频
游戏视图缺省不显示,只有点击与场景视图相邻的tab栏才会显示。与场景视图允许你移动游戏对象和观察整个场景不同的是,游戏视图展示玩家将会看到的内容,可能只是摄像机范围的一部分(摄像机范围在场景视图由有一个白色方框表示)。
Inspector windows(属性窗口)
当你在层级窗口或者项目窗口中选择一个条目时,属性窗口会展示其所有相关数据项。
视频
GameObjects(游戏对象)
对于场景中的游戏对象,属性窗口显示游戏对象的数据。
Unity使用了一种Object - Component(对象 - 组件)的模型,也就是说你的场景由添加了各种功能组件的游戏对象而组成。
举个例子,一个精灵渲染器组件在场景中游戏对象的位置显示了一幅图片,一个音源组件在场景中游戏对象的位置播放了一个声音。
所有游戏对象都有一个Transform component(变换组件),该组件使你可以指定对象在场景中的位置和旋转角。所有其它的组件是可选的,你可以按需添加。
Assets(资产)
对于资产,属性窗口显示Unity用到的导入设置。本系列辅导课将着重说明和解释用于2D游戏的资产的导入设置。
工具栏
工具栏包括最常用的工具按钮来帮你设计和测试游戏。
Play buttons
Play
播放按钮用来测试你当前加载到层级视图中的场景,这样你就可以在编辑器中实时试玩你的游戏了。
Pause
暂停按钮,就像你猜到的,让你可以暂停游戏视图中的游戏。它帮你发现你看不到的界面问题和游戏bug。
Step
单步按钮用来在暂停状态下逐帧前进。此功能在你寻找游戏中的实时变化时非常有帮助。
Manipulating objects(操作对象)
这些工具移动和操作场景视图中的游戏对象。点击以激活它们。
下表展示了每个工具用到的快捷键:
另一个好用的快捷键需要你记住:
鼠标导航
在场景视图中,你可以使用如下鼠标功能:
更多在场景中移动游戏对象的方法,参见:场景视图导航
缺省布局
你能以多种方式布局你的Unity编辑器。每种布局自有其优点,你可以找出最适合你的。
这里概览一下每种布局看看哪些可用。
要切换布局,选择 Window > Layout (或是使用 编辑器右方角的Layouts 下拉菜单)。选择下列布局的一种:
此处的设置因人而异。对于这个Ruby 2D 角色扮演项目随后的课程,我们会使用缺省布局,同时控制台窗口停靠在项目窗口旁边,就像你在辅导课中控制台那一节中看到的。整个辅导课中我们都将使用这个布局。
现在你已经初步了解了Unity编辑器是如何组织的。如果在随后的课程中提到“项目窗口”,你应该已经知道在哪里找到它了。一些资产已经添加到你的项目来帮你打造整个游戏。
在下一课中你可以开始创建你的游戏了。创建一个新场景,添加一幅图片给它,然后写下你的第一个脚本来让图片动起来。
本节课虽然不是核心课,但却是极其重要的基础课。不夸张地说,很多游戏开发环境的安装没有任何愉悦感,而是令人充满了沮丧,下载了A包,发现又要依赖B包,下载了B包又发现操作系统版本不对应,面对这种地狱般的感受,90%的同学直接放弃,另外10%的同学在踩了无数坑后放弃。而Unity的安装过程与之相比就非常人性化了,先安装Unity Hub,然后从下载开发平台到打开样例项目都会比较流畅(前提是网速够快),再加上本节课一步一步的贴心指导,只要严格遵守,那更是全程无痛无坑。
本节课要掌握三个要点:
原文链接
上一节课中,你已经学到了Unity编辑器的布局,了解了一个场景有游戏对象组成,而游戏对象上的组件决定了这些游戏对象如何在我们的游戏里运作。
现在是时候在你自己的场景中添加一个游戏对象了!
材料
Ruby.png
让我们开始创建一个工作用的新场景。
选择 File > New Scene。当然,你也可以使用快捷键 Ctrl + N(Windows/Linux)或者 Cmd + N(macOS)。
如果一个弹出窗口出现,提示未保存的修改,这是因为在演示场景中你移动或者改变了一些东西,Unity要确保你愿意放弃这些修改。在这里我们不做保存。
现在你有一个名为“Untitled”(未命名)的空场景 。这意味着场景还没有被保存到磁盘上。
让我们用一个合适的名字保存。选择 File > Save 或者使用快捷键 Ctrl/Cmd + S。
选中名为场景的目录,并把你的场景命名为“MainScene”(主场景)。
现在你已经有了一个工作场景,你可以在剩下的课程中使用此场景。记住经常用快捷键Ctrl/Cmd + S 来保存你的修改到磁盘。那样做了,如果你退出Unity,后边再返回,你也不会丢失任何修改。
public class RubyController : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
Vector2 position = transform.position;
position.x = position.x + 0.1f;
transform.position = position;
}
}
本节课主要掌握三个要点:
第一个要点是理解游戏对象(GameObject),Unity场景中万物皆GameObject,相机、精灵、网格都是。GameObject可以有子GameObject,子子孙孙,无穷尽也。从而以场景为根,形成了一棵有层级的树,而树中每个节点都是GameObject。精灵就是一个特殊的GameObject,它有个
第二个要点是理解游戏世界的坐标系统和坐标单位,Unity的游戏世界是一个左手坐标系,所谓左手坐标系,就是伸出左手拇指,蜷曲的四指从x轴正向指向y轴正向,则拇指所指方向为z轴正向,这种方法也称左手定则。对于2D游戏来说,使用左手定则可知,x从左到右、y轴从下到上,则z轴就是从屏幕外向屏幕内,所以从我们的眼睛出发,z坐标越小的物体越在前台越看得到,z坐标越大的物体越在后台越看不到。游戏世界的坐标单位不是一个具体的长度,而是以“Unit”为单位,这个单位可能是1cm,也可能是10m,它和设备的分辨率无关,而和我们游戏的内容相关。
第三个要点是理解脚本,脚本是精灵的行为逻辑控制部分,以组件的形式存在于精灵中,类似于C++、Java等面向对象的语言中对象所对应的类定义。不过这个脚本可没有那么自由,要通过几个回调函数来实现逻辑控制(所谓带着镣铐起舞)。几个典型的回调函数如Awake、Start、Update、FixedUpdate等,其回调时机各有不同。
原文链接
public class RubyController : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
}
// Update is called once per frame
void Update()
{
float horizontal = Input.GetAxis("Horizontal");
Debug.Log(horizontal);
Vector2 position = transform.position;
position.x = position.x + 0.1f * horizontal;
transform.position = position;
}
}
void Start()
{
QualitySettings.vSyncCount = 0;
Application.targetFrameRate = 10;
}
Public class RubyController : MonoBehaviour
{
// Start is called before the first frame update
void Start()
{
//QualitySettings.vSyncCount = 0;
//Application.targetFrameRate = 10;
}
// Update is called once per frame
void Update()
{
float horizontal = Input.GetAxis("Horizontal");
float vertical = Input.GetAxis("Vertical");
Vector2 position = transform.position;
position.x = position.x + 0.1f * horizontal * Time.deltaTime;
position.y = position.y + 0.1f * vertical * Time.deltaTime;
transform.position = position;
}
}
本节课要掌握三个要点:
第一个要点是理解Unity对于用户输入所抽象出的概念,例如轴域的取值为-1、0、1,代表移动的方向是反向、静止、正向。
第二个要点是理解如何设置一个合理的移动速度,应当使用“点/秒”而不是“点/帧”。
第三个要点是掌握写日志到控制台的方法,这对游戏的开发除错非常重要。
原文链接
Tilemap不能直接使用精灵,而是使用瓦片,所以要创建新Tile:
隐喻:Grid是画板,Tilemap是画纸,Tile是颜色,Palette是调色板
网格和瓦片以Unit为通用单位,由于此时网格的每单位像素(Pixel Per Unit,简称PPU)是100,而瓦片Sprite纹理的实际Cell Size是64x64,所以在SceneView中的Grid中出现了空隙。
上面讲述了用单个Tile来组成Palette的功能,Palette更强大之处是可以用Tileset(瓦片图集,含有多个Sprite的纹理图片文件)来一次生成多个Tile,通过查看Tile文件信息可以看出,Tile通过m_Name和guid来引用Sprite
本节介绍了Sprite编辑器的使用
本节介绍了如何把图片切成9个Sprites
把Tileset图片拖放到调色板编辑器即可
准备好了吗?开始绘制你的游戏世界!
眼下你不必绘制白色矩形框外部分。这个矩形框就是你的相机可视部分,也就是你在游戏视图中可以看到的部分。在以后的辅导课中,你会学会如何移动相机。
当你完成瓦片地图绘制时:
在本课中,我们学会了
本节要掌握三个要点:
第一个要点是整体把握瓦片地图涉及的所有概念和它们之间的关系。瓦片地图包括网格、瓦片地图、瓦片、调色板,以及之前学过的图片和精灵这些概念。网格可以有多个瓦片地图,瓦片地图由瓦片和调色板一起组成。每个瓦片都要有一个精灵,一幅图片可以切分为多个精灵。
第二个要点是导入图片和瓦片地图的PPU设置。
第三个要点是批量生成瓦片的快捷方法。
原文链接
在这个游戏中我们想要的效果是越在方(y坐标越小)的物体越优先看到。
本节要掌握Sprite的 Sprite Sort Point这个属性,这个属性决定了用哪个点的坐标y值来排序,属性可选项有两个,center中点或者pivot轴点。
注意当两个GameObject都使用center作为排序点时,当角色GameObject移动使得二者重合时,就会发生排序变化,导致后台的GameObject突然跳到前台,为了避免这个bug,必须小心的设计GameObject的排序点。例如纵向排列的角色和一个屏风,从现实世界讲,只要角色的脚在屏风下边沿上方,角色就应该被屏风遮挡。而角色的脚在屏风下边沿下方时,角色就应该完全出现。所以此时二者的排序点都应该选pivot类型,并把二者的pivot设置为bottom。
对于本例子中的Ruby和金属箱,也是一样的道理,同样应该把二者的pivot都设置为bottom。
Prefab是一个持久化的GameObject文件,编辑器中使用时被加载为一个起模版作用的GameObject,可以用它来创建新的GameObject。
现在你可以添加自己的装饰品了,在Art>Sprites>Environment目录中你可以找到很多,比如房屋,树木,井盖等。
导入这些资产,记住以下几点:
预制件非常重要!!!
本节课要掌握三个要点:
第一个要点是如何控制显示次序,2D游戏不通过Z坐标来修改,而是通过排序层和层顺序号来控制,这两个值均为相机位于正无穷看向负无穷。
第二个要点是理解精灵的支点的概念,所有的缩放,旋转,移动都是基于支点来进行的,这是一个规范化坐标,也就是0~1之间的值。
第三个要点是掌握预制件的使用,这可以大大加快游戏UI的设计,并且很方便调整同类对象的属性。
原文链接
Unity内置了物理系统以计算物体的移动和碰撞。为了提高性能,Unity只对有Rigidbody(刚体)2D组件的GameObject进行计算。
把Ruby Sprite拖放为预制件,在预制件中添加刚体2D组件
注意由于重力缺省作用于y轴,所以此时加入了刚体2D的GameObject会自动掉落,由于这个游戏实际上是“顶视图”,所有物体都是大地,所以要禁用其重力,方法是把刚体2D属性中的重力Scale设置为0.
碰撞体是类似方或圆的形状,用于物理系统进行碰撞计算。
为Ruby和箱子添加碰撞体之后,Ruby发生来颤抖和旋转现象。
首先,告诉“物理系统”不要旋转GameObject,尽管现实世界的碰撞会导致GameObject发生旋转,但是2D游戏不需要这个功能。可以在刚体2D组件的约束属性里冻结某个轴的旋转。
用户让角色先行移动,物理系统计算出发生碰撞,又把角色反向推回,如此反复,就发生来角色颤抖。
解决方法是让物理系统在角色真正移动之前就计算出发生来碰撞从而阻止角色移动。
不要直接把新位置赋值到transform,而是赋值到刚体,不管能否移动,物理系统会在计算过后把刚体位置同步到transform。
添加的是“Tilemap Collider 2D”组件。
添加Composite Collider 2D(组合式碰撞体2D),此时会自动添加一个Rigidbody 2D组件。
本节课要掌握两个要点:
第一个要点是物理系统的使用,为什么会出现角色颤抖和旋转的问题?如何解决这些问题。
第二个要点是瓦片地图如何使用碰撞体。
原文链接
既然你已经精心装饰了你的世界,并且和角色有了更好的交互性,是时候增加更多的可玩性了。
本节课中,你会学到另一个重要的游戏元素:触发器,它让角色可以收集混在其它物体之中的对象。
我们来示范一个收集健康包(李大数注:后文简称食物)来恢复主角血量的例子。不过在此之前,你需要修改角色的脚本来添加血量给它。
首先,创建了两个新变量:
public int maxHealth = 5;//公共变量,表示主角的最大生命状态。整型,显式设置为5.
int currentHealth;//不加修饰的变量作用域为私有,即本类内方可访问。整型,隐式设置为0.
maxHealth是主角的最大健康值,currentHealth是主角的当前健康值。
在Start函数中
currentHealth = maxHealth;
void ChangeHealth(int amount)//函数返回值 函数名(函数参数)
{
currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);//计算新的健康值
Debug.Log(currentHealth + "/" + maxHealth);//日志输出健康值
}
此刻选中场景中的角色,你会在属性窗口中的RubyController组件中看到一个新属性:
Max Health,其值为5,和我们的代码完全一致。可是我们分明定义了两个变量,为何这里只显示了一个?原因很简单,这两个变量的作用域不用,maxHealth是public型的,可以从外部访问,也就是可以从Unity编辑器访问,而另一个是private私有的,Unity编辑器无法访问,也就无法在属性窗口中显示了。
记得在变量声明时加public前缀即可。
既然你已经为主角添加了Health(李大数注:血量),下面让我们添加一种获取血量的方式。
为此,你需要使用触发器。触发器是一种特殊的碰撞体,它不会阻止移动,但是物理系统仍然会检查角色是否和它们发生了碰撞。角色进入一个触发器后发送一个消息给你,以便你处理该事件。
让我们开始创建一个可以收集的食物:
现在你可以添加代码来处理碰撞了:
void OnTriggerEnter2D(Collider2D other)
小贴士:确保你的函数名和参数类型不要拼写错误,因为Unity就是用它们来寻找该函数并回调的。
如同Unity每帧回调Update函数,它在检测到一个新的刚体进入触发器时的第一帧回调这个OnTriggerEnter2D函数。传入的参数是所进入的触发器的碰撞体。
public class HealthCollectible : MonoBehaviour
{
void OnTriggerEnter2D(Collider2D other)
{
Debug.Log("Object that entered the trigger : " + other);
}
}
让我们检查下你的代码:
让我们开始为RubyController脚本定义一个属性:
public class RubyController : MonoBehaviour
{
public float speed = 3.0f;
public int maxHealth = 5;
public int health { get { return currentHealth; }}
int currentHealth;
Rigidbody2D rigidbody2d;
// Start is called before the first frame update
void Start()
{
你已经像定义变量一样开始了属性定义:
你已经定义了一个属性,那么该如何使用呢?
让我们返回刚才的if语句:
if(controller.health < controller.maxHealth)
属性用起来就像变量而不像函数。这里,health 会给你currentHealth。但它只在你读取它但时候工作。
如果你想要把它改为:
controller.health = 10;
原文链接
在前一课中,你使用了一个触发器来检测Ruby是否触碰到血包。
本节课中,你会使用相同的知识在主角进入特定区域时受到伤害。
你还会增强敌人让它向前和向后移动,基于你学到的刚体组件相关知识,使用碰撞器而不是触发器。
首先,我们先确保Ruby的血量不是上节课我们所改的1:
添加一个可以伤害Ruby的区域。这有点像回血包,不过它会让血量减1并且触发后不会消失。
导航至 Assets > Art > Sprites > Environment 目录下,找到Damageable。
现在我们检查一下这个方案:
public class DamageZone : MonoBehaviour
{
void OnTriggerEnter2D(Collider2D other)
{
//通过collider对象尝试拿到Ruby的脚本组件,如果不为空则说明拿到了。
RubyController controller = other.GetComponent<RubyController >();
if (controller != null)
{
controller.ChangeHealth(-1);
}
}
}
相当不错,不过它只在主角进入区域时伤害主角。如果一直呆在伤害区里,它不会继续伤害。可以把OnTriggerEnter2D换成OnTriggerStay2D.这个函数每帧都会调用。
现在Ruby在伤害区会被持续伤害了,不过有点太快了!主角不到1秒就挂了,同时你还会发现主角尽管身处伤害区,但不移动的话也不会被伤害。
要修复后一个问题,你需要打开Ruby预制件,在Rigidbody组件里把Sleep Mode改为Never Sleep。
为了优化资源,物理系统在物体停止移动时不计算其碰撞体,这就是所谓的刚体的睡眠状态。但在我们的例子中,我们需要Ruby即使不移动也要计算其碰撞体。所以我们让其刚体永不睡眠。
接着我们修复Ruby“猝死”的问题。你可以让Ruby在一个很短的时间内处于“无敌”状体。该方式下,你可以忽略“无敌”态下受到的伤害。让我们对RubyController脚本做以下修改:
public class RubyController : MonoBehaviour
{
public float speed = 3.0f;
public int maxHealth = 5;
public float timeInvincible = 2.0f;
public int health { get { return currentHealth; }}
int currentHealth;
bool isInvincible;
float invincibleTimer;
Rigidbody2D rigidbody2d;
float horizontal;
float vertical;
// Start is called before the first frame update
void Start()
{
rigidbody2d = GetComponent<Rigidbody2D>();
currentHealth = maxHealth;
}
// Update is called once per frame
void Update()
{
horizontal = Input.GetAxis("Horizontal");
vertical = Input.GetAxis("Vertical");
if (isInvincible)
{
invincibleTimer -= Time.deltaTime;
if (invincibleTimer < 0)
isInvincible = false;
}
}
void FixedUpdate()
{
Vector2 position = rigidbody2d.position;
position.x = position.x + speed * horizontal * Time.deltaTime;
position.y = position.y + speed * vertical * Time.deltaTime;
rigidbody2d.MovePosition(position);
}
public void ChangeHealth(int amount)
{
if (amount < 0)
{
if (isInvincible)
return;
isInvincible = true;
invincibleTimer = timeInvincible;
}
currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
Debug.Log(currentHealth + "/" + maxHealth);
}
}
让我们深入理解脚本的修改细节!
让我们进入播放模式测试下脚本,如果你让Ruby呆在伤害区,timeInvincible设置为2秒,Ruby会每隔2秒受伤一次。在属性窗口中你可以把这个值改为你想要的。
从代码中回来休息一下,我们看看精灵渲染器这个功能。现在,如果你想要通过用Rect tool(T键)缩放伤害区的方式生成一个大伤害区,精灵将会被拉伸而且看上去很丑:
不过你可以让精灵渲染器平铺精灵而不是拉伸它。这样以来如果你缩放伤害区到足够容纳精灵两倍大小,精灵渲染器会一个挨一个的绘制精灵两次。
视频
要实现这个:
现在当你使用Rect Tool 矩形工具缩放游戏对象时你会看到他拉升知道他可以容纳两个精灵,并且他显示两个精灵而不是完全拉伸,请注意,这仅仅在你使用瑞克工具的时候工作不是比例攻取,因为比例工具改变了优秀。
在空寂的世界漫游了这么久,是时候添加一些其它角色了。让我们添加一些敌人,其实敌人也是一种伤害区。
保存下面的图片到你的计算机,导入Unity并放置到场景中。
就像你的主角,既然你想要敌人也能移动并和你的主角以及环境发生碰撞。它就也需要添加刚体和碰撞体。
别忘了设置重力比例为0,并且约束它不要绕z轴旋转。
视频
创建一个新脚本命名为EnemyController,并且附加到你的敌人角色上。现在我们编码这个脚本来让敌人上下循环游走。
基于之前所学你想必已经胸有成竹,你可以试试自己先实现再来学习本文提供的解决方案。这里是一些重要的提示:
下面是解决方案:
public class EnemyController2 : MonoBehaviour
{
public float speed;
Rigidbody2D rigidbody2D;
// Start is called before the first frame update
void Start()
{
rigidbody2D = GetComponent<Rigidbody2D>();
}
void FixedUpdate()
{
Vector2 position = rigidbody2D.position;
position.x = position.x + Time.deltaTime * speed;
rigidbody2D.MovePosition(position);
}
}
注意:tba
原文链接
原文链接
现在我们来看看如何创建子弹:
现在我们开始学习如何创建自己的物理系统:
Rigidbody2D rigidbody2d;
void Start()
{
rigidbody2d = GetComponent<Rigidbody2D>();
}
这个函数在刚体上调用AddForce,参数force是方向与力度的乘积,仍然是一个二维矢量。物理引擎将基于这个力度和方向来移动子弹。
public void Launch(Vector2 direction, float force)
{
rigidbody2d.AddForce(direction * force);
}
void OnCollisionEnter2D(Collision2D other)
{
//we also add a debug log to know what the projectile touch
Debug.Log("Projectile Collision with " + other.gameObject);
Destroy(gameObject);
}
现在你已经有个子弹的预制件,你需要把它扔出去来修好机器人:
在RubyController脚本中添加一个公共变量,命名为projectilePrefab。你需要使其为GameObject类型;GameObjects像资源一样保存。因为你已经把它public了,它会以一个槽的形式出现在你的编辑器中,你可以拖放任何游戏对象给它。
拖放子弹的预制件到槽中。记得如果你在场景中操作的话,它会覆盖到你的预制件(一个小蓝条在那个条目中),所以使用Overrides下拉在预制件中应用。
接着,在RubyController脚本中写一个发射函数,这个函数在你想要发射一棵子弹时调用(例如当键盘按键被按下时)。
void Launch()
{
GameObject projectileObject = Instantiate(projectilePrefab, rigidbody2d.position + Vector2.up * 0.5f, Quaternion.identity);
Projectile projectile = projectileObject.GetComponent<Projectile>();
projectile.Launch(lookDirection, 300);
animator.SetTrigger("Launch");
}
Instantiate是一个Unity函数。它以一个对象为首个参数来创建对象的拷贝,第二个参数是位置,第三个参数是旋转角度。
让我们看看实例化的步骤:
四元数是可以用来表达旋转的数学算子,不过这里你只要知道单位阵就是不旋转的意思就好。
力度值设的很高以便用牛顿单位来表示。对于你的游戏来说这是一个非常好的值,不过如果你想要玩得更激烈,你可以暴露一个公共浮点变量用作力度。
同时,你可以看到你的动画器已经设置了一个触发器,这会让你的动画器播放一个发射动画!
添加如下代码到Ruby Controller脚本的Update函数尾部:
if(Input.GetKeyDown(KeyCode.C))
{
Launch();
}
要发射一个齿轮你需要:
使用之前你见过的Input类,不过这次你要使用GetKeyDown,该函数允许你测试一个指定的按键是否被按下,此处我们使用键“C”。它只在键盘上工作。(李大数注:Input.GetKeyDown这个函数虽然每帧都会调用,但只在检测到按键down的那一帧中返回True,之后即被重置为False,并在后续帧中返回False状态,直到按键Up后重新开始检测,所以是安全的。)
如果你想要它能跨设备工作,你可以通过一个轴名来使用Input.GetButtonDown,就像你在移动中所做的,在输入设置中定义按键。例如Axes > Fire1.
现在按下Play,然后按下C来发射一个齿轮。 此刻你要么看不见齿轮,要么看见齿轮出现后马上消失。
你的控制台有两个错误行:
这是因为Unity不会在你创建对象时调用Start函数,而是在下一帧时才调用。所以当你调用发射函数时,仅仅是创建了实例而没有调用Start函数。所以你的刚体仍然为空。要修正这个问题,我们使用Awake这个回调函数。
与Start回调函数不同,Awake在对象创建时立即被调用(李大数注:类似C++的构造函数)。所以刚体在调用发射前被正确的初始化好了。
你可以试试在这种情况下不销毁子弹。但是你还是会面临碰撞时子弹停止移动的问题。
修复这个碰撞问题的正确方法是使用层。层使得你可以对GameObject进行分组以便整体过滤。你的目标是做一个角色层来放置你的Ruby游戏对象,一个子弹层来放置你的子弹。
然后你可以告诉你的物理系统,角色和子弹层不会碰撞,从而忽略所有这些层之间的对象的碰撞检测。
要检视游戏对象在哪个层,点击位于属性窗口的右上角的层下拉箭头。所有的对象开始时都在Default层(层号为0)。你的游戏可以有多达32个不同的层。
如果你点击它,一个弹出窗口会出现,上面有一些内建的预定义层。不过你需要创建自己的层,所以选择Add Layer,这会打开层管理器。
层0-7被Unity锁定,你无法修改。所以我们在Layer8输入Charactor,Layer9输入Projectile。
现在打开Ruby预制件,把它的层改为角色然后保存。同样修改Projectil预制件的层为Projectile。
然后打开 Edit > Project Settings > Physics 2D ,注意面板底部的层碰撞矩阵,看看哪些层之间可以碰撞。
在缺省情况下,所有层都勾选,所以所有层都与其它层碰撞,但你可以不勾选角色层和子弹层,这样这两个层上的物体就不会碰撞了。
现在你可以进入Play模式,你的齿轮不会再和Ruby碰撞了,但它仍然可以和其它物体比如箱子或是敌人发生碰撞。
第一步是在Enemy脚本中写一个函数来修理机器人和处理机器人被修好后如何反应。眼下,你的子弹仅仅是在碰撞时销毁了自己。但我们的目标是用齿轮子弹修好发疯的机器人。
要修好你的机器人:
4. 添加一个布尔变量broken,初始化为True。(李大数注:表示这个机器人发疯了,会四处移动攻击主角。)
5. 在Update和FixedUpdate函数开始处,测试机器人是不是疯了,如果没有疯就直接返回。
当机器人修好了,你的机器人就不会四处移动了。
因此,在update函数中早点返回就可以让它不再移动了:
void Update()
{
//remember ! inverse the test, so if broken is true !broken will be false and return won’t be executed.
if(!broken)
{
return;
}
void FixedUpdate()
{
if(!broken)
{
return;
}
//Public because we want to call it from elsewhere like the projectile script
public void Fix()
{
broken = false;
rigidbody2D.simulated = false;
}
你在这里设置了broken为假,刚体的模拟属性为假。
这将把刚体从物理系统的模拟中移除,所以它不会再介入到碰撞计算中,并且修好的机器人也不再会阻挡子弹或者伤害主角。
4. 现在剩下的就是修改子弹脚本的OnCollisionEnter2D函数了。从子弹的碰撞对象中获取到EnemyController组件,如果获取成功,就表示你已经修好了疯机器人。
void OnCollisionEnter2D(Collision2D other)
{
EnemyController e = other.collider.GetComponent<EnemyController>();
if (e != null)
{
e.Fix();
}
Destroy(gameObject);
}
注意:你也移除了Debug.log ,因为已经不再需要。
现在子弹将修好机器人,它将不再移动,你可以朝它走并且不会被伤害了。
一个小问题,如果你让Ruby扔出一个齿轮而没有碰到任何东西。这个齿轮会在整个游戏运行时一直运动,甚至飞出屏幕。
在游戏进程中,这可能会导致性能问题,特别是突然你有500个齿轮在屏幕外面飘荡时。
要修复这个问题,你只需要简单地检查下齿轮和世界中心的距离,如果远到Ruby不可能在遇到它(对你的游戏来说是1000单元),那你就可以销毁齿轮了。
让我们添加这个代码到齿轮子弹脚本的Update函数中:
void Update()
{
if(transform.position.magnitude > 1000.0f)
{
Destroy(gameObject);
}
}
记住Position可以被看作从对象所处世界的中心发出的一个向量,magnitude是这个向量的长度,即也就是到中心的距离。
当然有其它的方式来处理,这依赖与游戏。举个例子,你可以获取主角和齿轮的距离(使用**Vector3.Distance(a,b)**这个函数来计算位置a和位置b的距离)。
或者你也可以使用一个定时器,当齿轮发射设置4秒的定时,你可以在Update函数中递减它,当它到0时销毁齿轮。
这一步是可选的,它并没有为游戏添加功能,也和本节课谈到的子弹没有关系,但它让游戏更悦目。
你将为修好的机器人添加一个动画。如何使用动画器创建动画请参见之前课程。
animator.SetTrigger("Fixed");
进入Play模式,发射你的齿轮到机器人身上来修复它。它应该会幸福的舞蹈!
此刻RubyController脚本内容如下:
public class RubyController : MonoBehaviour
{
public float speed = 3.0f;
public int maxHealth = 5;
public GameObject projectilePrefab;
public int health { get { return currentHealth; }}
int currentHealth;
public float timeInvincible = 2.0f;
bool isInvincible;
float invincibleTimer;
Rigidbody2D rigidbody2d;
float horizontal;
float vertical;
Animator animator;
Vector2 lookDirection = new Vector2(1,0);
// Start is called before the first frame update
void Start()
{
rigidbody2d = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
currentHealth = maxHealth;
}
// Update is called once per frame
void Update()
{
horizontal = Input.GetAxis("Horizontal");
vertical = Input.GetAxis("Vertical");
Vector2 move = new Vector2(horizontal, vertical);
if(!Mathf.Approximately(move.x, 0.0f) || !Mathf.Approximately(move.y, 0.0f))
{
lookDirection.Set(move.x, move.y);
lookDirection.Normalize();
}
animator.SetFloat("Look X", lookDirection.x);
animator.SetFloat("Look Y", lookDirection.y);
animator.SetFloat("Speed", move.magnitude);
if (isInvincible)
{
invincibleTimer -= Time.deltaTime;
if (invincibleTimer < 0)
isInvincible = false;
}
if(Input.GetKeyDown(KeyCode.C))
{
Launch();
}
}
void FixedUpdate()
{
Vector2 position = rigidbody2d.position;
position.x = position.x + speed * horizontal * Time.deltaTime;
position.y = position.y + speed * vertical * Time.deltaTime;
rigidbody2d.MovePosition(position);
}
public void ChangeHealth(int amount)
{
if (amount < 0)
{
if (isInvincible)
return;
isInvincible = true;
invincibleTimer = timeInvincible;
}
currentHealth = Mathf.Clamp(currentHealth + amount, 0, maxHealth);
Debug.Log(currentHealth + "/" + maxHealth);
}
void Launch()
{
GameObject projectileObject = Instantiate(projectilePrefab, rigidbody2d.position + Vector2.up * 0.5f, Quaternion.identity);
Projectile projectile = projectileObject.GetComponent<Projectile>();
projectile.Launch(lookDirection, 300);
animator.SetTrigger("Launch");
}
}
你的EnemyController脚本内容:
public class EnemyController : MonoBehaviour
{
public float speed;
public bool vertical;
public float changeTime = 3.0f;
Rigidbody2D rigidbody2D;
float timer;
int direction = 1;
bool broken = true;
Animator animator;
// Start is called before the first frame update
void Start()
{
rigidbody2D = GetComponent<Rigidbody2D>();
timer = changeTime;
animator = GetComponent<Animator>();
}
void Update()
{
//remember ! inverse the test, so if broken is true !broken will be false and return won’t be executed.
if(!broken)
{
return;
}
timer -= Time.deltaTime;
if (timer < 0)
{
direction = -direction;
timer = changeTime;
}
}
void FixedUpdate()
{
//remember ! inverse the test, so if broken is true !broken will be false and return won’t be executed.
if(!broken)
{
return;
}
Vector2 position = rigidbody2D.position;
if (vertical)
{
position.y = position.y + Time.deltaTime * speed * direction;
animator.SetFloat("Move X", 0);
animator.SetFloat("Move Y", direction);
}
else
{
position.x = position.x + Time.deltaTime * speed * direction;
animator.SetFloat("Move X", direction);
animator.SetFloat("Move Y", 0);
}
rigidbody2D.MovePosition(position);
}
void OnCollisionEnter2D(Collision2D other)
{
RubyController player = other.gameObject.GetComponent<RubyController >();
if (player != null)
{
player.ChangeHealth(-1);
}
}
//Public because we want to call it from elsewhere like the projectile script
public void Fix()
{
broken = false;
rigidbody2D.simulated = false;
//optional if you added the fixed animation
animator.SetTrigger("Fixed");
}
}
你的子弹脚本内容为:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Projectile : MonoBehaviour
{
Rigidbody2D rigidbody2d;
void Awake()
{
rigidbody2d = GetComponent<Rigidbody2D>();
}
public void Launch(Vector2 direction, float force)
{
rigidbody2d.AddForce(direction * force);
}
void Update()
{
if(transform.position.magnitude > 1000.0f)
{
Destroy(gameObject);
}
}
void OnCollisionEnter2D(Collision2D other)
{
EnemyController e = other.collider.GetComponent<EnemyController>();
if (e != null)
{
e.Fix();
}
Destroy(gameObject);
}
}
本节课深入讲述了物理系统以及层的使用和力如何作用于刚体来让物体移动。
在下节课中,你将通过让摄像机跟随主角来把世界变得更大。
本节课要掌握以下三个要点:
tba
原文链接
目前为止,你的游戏已经在一个屏幕上运行起来了,所以你已经使用了一个静态摄像机。
本节课中,你将使用一个名为Cinemachine的Unity包来自动控制你的摄像机而无需任何代码。你想让摄像机跟随你的主角以让它探索更大的世界。
注意:这节课需要网络连接,因为你将会下载Cinemachine包。
Unity附带了大部分游戏和应用都会用到的很多内置功能。更多不是所有游戏都会用到的特殊功能以包的形式提供。包仅仅增加了那些用到它们的项目的大小,并且很快捷和易于更新。
一个包是代码和资源的打包集合,你可以通过包管理器把它添加到你的项目。它们为你的项目添加功能让你无需编码,例如虚拟现实支持,后处理效果或者此处你正在寻找的:附加摄像机功能。
要添加Cinemachine包:
在Unity编辑器中打开包管理器(菜单:Windows > Package Manager).
Install按钮会变成Installing,当包完成导入时,它会变成Up to date。你现在拥有了Cinemachine,这是一个用来在你的工程里设置和移动摄像机的工具。
小贴士:点击View documentation版本号下方的蓝色链接可以查看包的相关文档。
电影模式让你可以创建复杂的3D摄像机设置,允许在多个相机之间移动和剪切。
你即将用它来简单地创建一个2D方式下跟随目标的摄像机。
电影模式也包含2D辅助功能来约束摄像机到一定的边界,所以它不会在你的地图边界之外显示对象。
要让你的角色返回场景,你将使用Layers,就像你在上节课对子弹所做的。让我们简单回忆下步骤:
本节课你已经导入了Cinemachine Package到Unity,并且使用它处理了摄像机的移动。
要获取使用Cinemachine的更多信息,看看文档,特别是你想用虚拟摄像机的属性来播放的时候,例如阻尼运动和跟随,以及跟随速度。
小贴士:关注下包管理器,因为它包含了大量可以帮你创建游戏,减小工作量的工具包。
下节课你会学到一些图形增强技术,通过使用粒子系统。
原文
粒子被广泛地应用于交互式应用效果。一个粒子系统创建数十甚至上百的粒子。所谓粒子,就是有方向、速度、生命期、颜色和许多其它属性的小图片。通过播放这些参数,粒子可以聚合创建出像烟雾,火花甚至火焰这样的效果。
Unity提供了一套广泛的粒子系统。本节课你可以用它为我们的游戏来创建一些粒子,例如疯狂机器人的烟雾效果。
烟雾效果
你将会使用精灵作为你的粒子群的影像。用于这些影像的精灵图集位于 Art > Sprites > VFX目录下,名为ParticleSheet。
要创建一个新的粒子效果:
通常这个段中的设置用来随着时间流逝而动画化粒子图片。不过这里你只是用它来为每个粒子选择一个随机精灵。
现在你可以在场景视图中看到粒子群了,tba
下一步是去调整它们如何被创建,因为此刻它们扩散的太远,扩散方向也太多。
在属性窗口中打开Shape section,场景视图将会显示粒子群发射的锥形。设置半径为0,因为你想要所有粒子从一个点开始(Unity中这个值会变成0.0001,不过不用担心,这只是因为该值不能为0,所以Unity把它设置为最接近的值)。
改变粒子扩散角度在5左右 tba
你的粒子群现在会以正确的方向开始,不过它们还是移动得太快了。另外由于所有的粒子形状相同,它们看起来很像人工产物。正常看到的形状是杂乱无章的,所以让一些东西看起来更自然的秘诀就是给它添加一些随机性。
在粒子系统顶部的主要段中,找到这三个设置:
点击小下拉箭头tba
2. Start Size:这是粒子创建时的大小。此刻被设置为一个单精度浮点,故而所有粒子都是相同大小。我们通过选择Random Between Two Constants引入一点随机性,设置为0.3到0.5。粒子现在以各种不同尺寸变小了,不过还是移动得太快。
3. Start Speed:如法炮制,设置为0.5到1。
粒子群开始看上去更像烟雾了,不过还是有点奇怪,因为当粒子们到达生命期终点时,它们仅仅升起并突兀地消失。
视频
简而言之,我们要优雅地改变粒子的透明度,直到粒子的生命期终点变成完全透明。
在这种方式下,粒子不会突兀地消失,而是慢慢地淡出消失。
你会看到左侧的颜色和alpha tba
3. 现在,粒子会整个保持白色并且alpha不会改变。所以我们对它进行修改:选择顶部右方箭头,把alpha从255改为0。
视频
快到终点了!你的烟雾现在看上去好多了,不过还遗漏了一个小细节:烟雾应该随着时间流逝而逐渐熄灭,所以粒子的大小在生命期也应该改变,逐渐地变小。
就像对颜色所做的,有一个段叫做Size Over Lifetime,所以启用并打开那个段。
点击Size矩形,查看属性窗口底部出现的曲线:
此刻粒子系统所做的与你想要的恰好相反,因为粒子的尺寸在生命期内从小到大(注意这只是一个系数,所以它不会破坏我们之前设置的随机初始尺寸)。
让我们把它反过来,通过拖拽第一个点到1,最后一个点降低为0。
注意:你可以使用出现在每个点旁的正切来控制曲线,在场景中播放直到达到你满意的效果为止。对于一个好看的烟雾,你需要让曲线前半程平坦然后来个陡峭的降低。
视频
到这里你的烟雾看起来已经非常完美了,是时候把它添加到你的机器人上了。
让我们看看代码中的粒子系统
制作一个来自你的烟雾效果的预制件。然后你就可以删除场景中的那一个了。
打开你的机器人预制件,然后拖动你的烟雾预制件为你的机器人预制件的子元素,置于合适的位置让它看起来像是从头部发出的:
视频
保存你的预制件,试玩一下。机器人现在看上去更疯狂了。不过出现了两个问题:
烟雾移动的修复是非常简单的:
这使得你不必像之前一样调用GetComponent。这也防止你赋值一个不拥有此组件的游戏对象到属性窗口设置,进而避免bug产生。
视频
4. 最后,在EnemyController的Fix函数中添加如下代码:
smokeEffect.Stop();
你的烟雾将会在修好机器人后停止。
此处有个问题要注意,为何这里要使用Stop,而不是像之前对子弹所做的一样简单销毁粒子系统?
实践出真知:
Stop,与之相反,只是简单地停止粒子系统继续创建新的粒子,已经创建的粒子在其生命期到达前依旧存在。这就比一下子看到所有粒子全消失自然多了。
既然你知道了粒子系统是如何工作的,你可以尝试为你的游戏创建更多的粒子。本节课开始提到的精灵表里有一个样例粒子效果可以用于撞击和捡起医药包。
别担心做的不对,大胆创造和尝试各种不同的设置,在实践和错误中学会它的整个工作方式。
这里有一些信息可以帮你做到你期望的:
循环系统
你之前做的烟雾效果是无限循环的,所以它一直在产生新粒子。不过你也可以创建出只工作一定时间就销毁的粒子系统。要做到这个,导航至属性窗口中的Particle System section:
爆炸式发射
你的烟雾效果以一个稳定的速率发射粒子。你可以在Emission段来设置这个速率。
Rate over Time 控制每秒发射多少粒子。tba
原文链接
课程至此,你已经学会了使用Debug.Log在你的主角受伤时输出你当前的生命值。但是成品游戏的玩家不会有一个控制台窗口来查看日志。所以为了给他们反馈,交互式应用使用用户界面(简称UI),它覆盖了图片和文本在你的屏幕上来显示信息。这也被称为抬头显示(HUD)。(李大数注:HUD,抬头显示器,本是汽车上的一种设备概念,被用在了这里,也是颇为有趣。)
本节课你将学习添加UI到你的项目来显示主角的血量。
Unity的UI用一种叫做Canvas的组件GameObject来渲染诸如图片,滑动条、按钮之类的控件。一个画板定义了每个UI元素如何渲染到屏幕上,并且渲染其所有的子UI元素。
要创建一个UI:
第一步是创建画板。在层级窗口中,右击选择UI > Canvas。
当你完成这步时,你会发现另一个GameObject也被加入了场景,它叫做事件系统。这也是一个游戏对象,它处理和UI的交互事件,例如鼠标点击。这里暂时还用不到它,但得留着它,否则画板会报警告日志。
第一处不同是这个GameObject有一个矩形变换组件取代了普通GameObject中的变换。
一个变换矩形仍然是一个变换的子类,所以脚本中可以把它像变换一样使用,不过你过会儿会看到它有一些增加的UI数据。此刻,我们先把它看做变换。
画板定义了UI如何显示在游戏中。画板可以是以下模式:
在本节的例子中,你要保持画板模式为缺省值,即屏幕空间覆盖模式,因为你只是想显示Ruby的生命值。
画板有一个Canvas Scaler组件,定义了UI在不同的屏幕尺寸下进行比例变换。玩家可能在800x600,1920x1080等不同等分辨率下运行游戏。此外对于移动应用,app可能横屏也可能竖屏。所有这些情况都需要不同的屏幕尺寸和纵横比。
你可以设置Canvas Scaler模式为:
现在画板已经就位,你可以添加血条到UI了。它由左右两部分UI组成:
第一部分:左边是Ruby肖像的头像背景图。
第二部分:右边是一个计量血量的槽。蓝色部分会随着角色的生命值而变化。
(李大数注:这两部分是从视觉元素出发得到的概念,并非UI元素组成的层次说明,不要被误导)
要添加一幅图片:
但是如果你查看场景视图,白色方形就不见了。这是因为场景视图和游戏视图处理UI位置和大小的方式有一些不同。
到此为止,每个物体都以“单元”为度量,所以沿一条轴方向移动一个10单元表示世界中的10个单位,但我们的UI元素的矩形变换在这里以像素为单位。
所以如果你的屏幕是800像素宽,你的图片位置是400,它就会出现在屏幕的中间,但在场景视图中它会出现在400单元处,太远(也太大)导致我们看不到。
让我们把白色块儿改为我们想要的图片,拖动Art>Sprites>UI中的名为UIHealthFrame的精灵血条游戏对象的Source Image属性中。
视频
图片被挤压得满满当当,这是因为它保持矩形的大小。
要通过改变图片的矩形变换来改变图片的大小,请点击图片属性中的Set Native Size按钮。现在你的图片应该变得巨大无比。
这是因为你的图片是1336像素宽,但你但屏幕比这小,不过你可以缩放它到合适的大小。
选择你的图片,确保工具栏中的Rect Tool被选中(快捷键:T)。
现在拖动角或者边来调整图片大小。如果你按住shift键,则会保持纵横比缩放。
你也可以点击并拖拽图片进行移动,它会紧贴画板的边角。让我们把它放置在画板左上角。
视频
你总是可以打开游戏视图来检查你的游戏元素的外观。放置图片只不过是第一步。
这时候,如果你改变屏幕大小(例如,改变游戏视图的大小),元素的放置状态会发生变化:
视频
你的图片被锚定在屏幕的中心,但什么是锚点?看看你的场景视图中选中的图片:
屏幕的中心就是你的图片的锚点。那也是计算你的对象位置的起点(与之相联系的是图片的支点)
所以当屏幕缩放时,图片位置是不变的,图片不会和屏幕边界一起移动。
为了处理这种情况,你需要锚定图片到屏幕角上。当屏幕缩放导致角落移动时,你的图片也会移动相同的距离。
要锚定图片到角落:
请注意此时矩形变换中的Pos X和Pos Y也同时发生了变化,这是因为锚点的改变导致图片坐标重新计算。
确保你的图片在角上,这样在你缩放屏幕时,UI始终在角上!
你可以重命名这个游戏对象为Health,这样我们就可以知道它是血条UI。使用属性窗口顶端包含Image的文本框,把它改成Health。
视频
到了添加Ruby头像和蓝色生命条来完成我们的UI的时候了。
要添加头像:
在Art>Sprite>UI目录下找到名为CharacterPortrait的头像图片
给Health Image创建一个新的子Image,把头像赋给它,点击Set Native Size后缩放大小(记得按住shift键以保持纵横比而不要挤压它)。缩放并且移动它到Health Bar背景的蓝圈处,直到你满意这个结果。
你无需在此改变锚点,我们这个case中就让它锚定在父对象的中心。这样当我们的血条因为屏幕缩放而移动时,它的中心也会移动,从而头像也会移动。
不过如果你对Health bar水平缩放时,它会改变中点从而改变头像位置,头像也会被缩放。
视频
这是因为锚点是一个参考点,它同时影响了位置和大小。如果你在Rect Transform矩形变换组件中按下anchor button锚点按钮,并且选择右下方的扩展蓝色箭头,你会看到4个箭头移到父图片上。
现在你的图片大小已经不是绝对大小而是相对于这些锚点的距离比例了。所以如果你的图片尺寸是左侧锚点到右侧锚点距离的25%,当这个锚点接近时,你的图片仍然会保持相同比例。
视频
3. 为了合适的缩放血槽,要把你的矩形变换属性改为拉伸式并且移动锚点。你可以点击并且拖放到围着你头像的合适位置。
视频
现在你需要添加蓝色血量条和想办法在主角受伤时减少血量条了。
你可以简单地设置血量条的比例来实现,然而这会导致血量条的挤压变形。
视频
取代方案是使用蒙板。蒙板是一种UI系统的技巧,让你可以把一幅图片作为另一幅图片的“蒙板”来使用。把你的第一幅图看作一个模板,第二幅图重叠于第一幅图的部分可见,其余则不可见。
要创建一个血量条蒙板:
血量条的可视部分已经完成,让我们看看脚本如何处理。
public class UIHealthBar : MonoBehaviour
{
public Image mask;
float originalSize;
void Start()
{
originalSize = mask.rectTransform.rect.width;
}
public void SetValue(float value)
{
mask.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, originalSize * value);
}
}
当血量在0到1(1为满血,0.5为半血,以此类推)之间变化时 ,你的代码将调用SetValue。这会改变我们的Mask的尺寸,接着Mask会隐藏血条的右边部分。
现在如果回到Unity编辑器让你的脚本被编译一下,你会得到一个错误“The type or namespace name ‘Image’ could not be found”。这是因为Image不是UnityEngine的名字空间的一部分,而是其子分类UnityEngine.UI的一部分。
我们可以通过完整输入UnityEngine.UI.Image来修复这个问题,不过你可能会有疑问,GameObject是UnityEngine名字空间的一部分,为何并没有完整输入UnityEngine.GameObject就能使用它?
看看你的脚本的最上面几行,using关键字实现了把包名字空间的所有类型导入脚本。
比如这一行"using UnityEngine;"就导入了UnityEngine中的所有类型,这样就可以无需在类型前再键入UnityEngine了。同理添加“using UnityEngine.UI"后就可以直接使用Image,当然代码也可以顺利编译通过。
最后要做的是在主角血量改变时调整血条这个UI元素,你需要从RubyController脚本的ChangeHealth函数中调用SetValue来提供一个新的血条显示量。
让我们仔细看看这种通过使用一个静态成员来引用某个东东的新方法。之前,当你在脚本中创建一个成员时,每个实例中都会有一份拷贝。而静态成员是所有实例共享该成员变量。我们用类名.变量名来访问它。我们之前用过的Time.deltaTime也是一个静态成员变量。静态成员还可以是一个函数,例如我们用过的Debug.Log。
在这个case中,你想要在其它任何脚本中不通过UIHealthBar的引用来访问,那我们就要做如下修改:
public class UIHealthBar : MonoBehaviour
{
public static UIHealthBar instance { get; private set; }
public Image mask;
float originalSize;
void Awake()
{
instance = this;
}
void Start()
{
originalSize = mask.rectTransform.rect.width;
}
public void SetValue(float value)
{
mask.rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, originalSize * value);
}
}
让我们深入看看有哪些改变:
static UIHealthBar instance property,血条实例这个属性是静态和公共的,所以你可以在任意脚本通过UIHealthBar.instance来调用其get属性。不过set属性仍然是私有的,因为我们不想从脚本外部修改它。
在你的Awake函数(记住此函数在对象创建时马上调用,在我们的例子里对象在游戏启动时就被创建了)中我们存储这个静态实例为this。this是一个特殊的C#关键字,用来表示当前运行的成员函数所操作的对象。
当游戏启动时,HealthBar脚本的Awake被调用,Awake函数中存放自身引用到其静态成员“instance”中。从而其它脚本调用UIHealthBar.instance时就会返回我们场景中的血条脚本。
你现在有一个场景中血条脚本的引用,无需再次在属性窗口中手动赋值了。为何不把所有对象都这么用呢?正如你之前看到的,静态成员被脚本的所有实例共享,也就是所有挂接了这个脚本的游戏对象中该类型的对象都是同个对象。
如果你的场景中有两个血条,第二个将也把自身存储在静态成员中,替换了第一个。导致UIHealthBar.instance总是返回第二个。这就是所谓的Singleton(单件)模式,因为该类型只有一个对象可以存在。这精确符合你的需求:只需要一个血条。
现在让我们在游戏中动态刷新血条。打开RubyController脚本,在ChangeHealth函数中,把Debug.Log这一行替换为:
UIHealthBar.instance.SetValue(currentHealth / (float)maxHealth);
你现在可以添加UIHealthBar脚本到你的血条游戏对象了,拖动你的蒙板到属性窗口中的Mask属性,进入游戏模式。如果Ruby被敌人或者伤害区伤害。其血条会相应刷新。
你的RubyController脚本现在应该看起来像这样:(李大数注:此处代码省略,见原文链接)
你的UIHealthBar脚本现在应该看起来像这样:(李大数注:此处代码省略,见原文链接)
本节课你已经看到了Unity如何渲染一个UI,以及你如何在编辑器中使用Rect tool来放置和缩放元素以便它在各种比例下良好工作。
你也学到了如果在脚本中使用静态成员,以及单件模式,让你的UIHealthBar脚本可以被任意地点访问。
下节课你将添加一个你可以与之谈话的角色来增强你的UI,同时介绍一个用于视频游戏制作的重要概念:射线检测。
本节课核心要掌握三个要点:
第一是锚点的概念,这可以说是Unity的一个重大创新概念,Unity的锚点不但可以是一个点,还可以是一个矩形区域(四个锚点形成一个锚区),这样当父对象缩放时,既影响到了子对象的位置,也影响到了子对象的大小,子对象的大小会随着锚区的缩放按比例变换。
第二个要点就是“蒙板”的概念,这里的蒙板与我们直觉的蒙板概念恰恰相关,我们直觉的蒙板类似面具,指的是不透明材质组成的部分,蒙板覆盖的部分是不可见的,没被覆盖的部分是可见的(想象一下火影中卡卡西的面具)。而Unity的蒙板是被覆盖的部分是可见的,没被覆盖的部分是不可见的,它指的是由透明材质组成的部分,我们称其为“透板”可能更便于形象记忆。
第三个要点是“单件”,这是程序开发领域中设计模式相关的概念,称其为最重要的设计模式也不为过,通过单件,我们可以快速访问所有全局唯一的对象,比如音频管理器,资源管理器等等。
原文链接
Ruby现在已经可以修好疯狂的机器人了,不过她仍然是这个孤独世界上唯一的居民。让我们添加另一个角色到场景中。为了让新角色有趣一点,我们让Ruby可以和它谈话,并且从它那里知道自己的“小目标”:修好所有的机器人!
第一个任务是创建角色,一只叫做Jambi的青蛙。现在你已经熟悉了Unity,那就让我们直截了当搞快点。为了让事情简单点,这个角色使用一个单循环动画。
要用到的精灵图集是位于Art>Sprites>Characters目录下的JambiSheet。
切图为4x4Cells
导入精灵来创建GameObject角色
小提示:如果你在你的项目目录中选择三个精灵,并且一次性拖放它们到层级窗口,Unity不但为创建新对象,同时自动为这个对象创建由这些精灵生成的动画。一切都是自动的,无需其他操作。(李大数:这个操作技巧非常方便和实用,建议大家一定要熟练掌握)
视频
回到项目窗口中你的Jambi精灵图集, 修改图片的PPU让它看起来更好一点,改变数值后记得要Apply一下,这个case中150比较合适。
添加一个Box Collider 2D组件,并且调整其比例使得它能覆盖角色的底部,就像我们之前对主角和敌人所做的一样。
创建一个名为“NPC”(李大数:Non Player Character,非玩家角色)层,把你的角色对象至于其上。
最后,重命名GameObject为Jambi,并且用它做一个预制件。
你现在可以进入游戏模式,测试下你的角色的动画和碰撞行为是否正确。
如果自动创建的动画看起来运行得太快,你可以在动画窗口(Menu Windows > Animation > Animation)中调整采样率。就像之前你处理动画剪辑一样。
现在你需要添加一些代码,以便Ruby可以和你的青蛙角色谈话。首先,你需要知道Ruby是否站在那个角色的前方。
你可以在那个角色的前方放置一个触发器,当Ruby经过触发器是,对话即开始。但这也意味着即使Ruby背朝青蛙时仍然可以与其对话。
因此,我们应当在交互式应用中使用一种物理系统相当有用的功能:射线检测。
Raycasting是在场景中投射一道射线以检查射线是否与碰撞体相交。一道射线由起点,方向和长度组成(可以想象为射线段)。“投射“一道射线这样的术语表示从起点到终点沿着射线方向进行相交的测试。
这里你将要投射的一道射线,应当从主角的位置开始,沿着主角目光方向,长度为1到1.5个单元。
为了实现这个,添加如下代码到你的 RubyController 的 Update function :
//李大数:检测“X”键是否被按下
if (Input.GetKeyDown(KeyCode.X))
{
//李大数:调用2D物理引擎的射线检测方法,传入起点、方向、长度、过滤层掩码,返回Hit对象
RaycastHit2D hit = Physics2D.Raycast(string point, dir,len,LayerMask.GetMask("NPC");
//李大数:如果Hit对象内的碰撞体为空,说明没有发生碰撞
if(hit.collider != null)
{
//李大数:不为空,说明碰撞到了NPC,在这里简单Log出碰到的对象的名字
Debug.Log(hit.collider.gameObject);
}
}
处于简化目的,你将让对话框在青蛙朋友Jambi的上方出现。
你会使用一个Canvas,但这次我们将会让画板出现在世界空间中。也就是说画板在游戏世界中真实存在,就在Jambi的头顶,而不是永远盖在屏幕上。
要添加Canvas:
在层级窗口中右击Jambi(或者先选择Jambi然后点击层级窗口顶端的Create按钮)
选择UI > Canvas。此操作为Jambi创建一个子Canvas GameObject。(李大数注:注意场景视图上方工具栏的“Gizmos”为”按下“状态才能看见Canvas的边框)
此刻你的Canvas叠在屏幕上。选中Canvas,在属性窗口中把Render Mode改为World Space。
你可以忽略Event Camera设置,因为它只在类似按钮按下之类操作的UI交互中用到,此处你只是想在画板上显示文本而已。
刚刚生成的Canvas太大了。这个Canvas的尺寸是像素,我们可以在Rect Transform中看到它的Width和Height都是以百计(这个数值可能不同,取决于你在把画板切换到世界空间时你的游戏视图的大小)。
你可以改变这些数值以给予画板一个合适的大小(比如3x2),但这令你构建UI更麻烦了。
所有的UI元素(例如图片和文本)都以像素为单位,所以一个3x2的Canvas大小会是一个3x2像素的盒子。
故而,你应该对画板做比例变换,以便其保持合适的尺寸。
设置你的Rect Transform(矩形变换):
坐标x,y都为0
宽度300,高度200
比例x,y,z都为0.01
此刻你的画板已经在场景中有个合适的大小,让我们把它移到青蛙角色的上方。
添加一个图片:
选中青蛙的画板子对象。
右击GameObject,选择 UI > Image 。
在项目窗口中,导航至 Assets/Art/Sprites/UI目录。
选择UIDialogBox精灵,拖到GameObject的Source Image字段。
别忘记扩展图片以填满画板,在Rect Transform(矩形变换)中按下Alt键的同时选择bottom right handle。
视频
你可能注意到你的画板图片出现在你的场景中某些元素的后面。这是因为你的画板也存在于场景中,所以它像任何其它游戏对象一样可以在其它游戏对象之后渲染。
要确保画板在最顶端渲染,选择层次窗口中的画板,在属性窗口中,设置Order in Layer为一个较高的值(例如10):
要添加文本到画板,我们需要:
点击Import TMP Essentials(导入项目必要的Text Mesh Pro 资产)。当导入完成时(Import TMP Essentials按钮会变为灰色不可用),你就可以关闭此窗口了。而你的文本此刻已经就位。
就像之前调整图片一样,通过按住Alt键同时streach方式扩展文本为整个图片的大小
然后用小白手柄来移动黄色的文本外框,给文本框一个边缘留白。
视频
由于Unity缺省只提供了西文字库(字库中只有数字和英文的字模图形),所以原文中的方法只适用于英文显示,如果在文本框中输入中文,会因为缺少中文字模而显示为空白格,所以在这里补充制作Text Mesh Pro所支持的中文字库(字库中包括数字、英文和中文)的方法。
这个字库应该足够全,包括所有西文字符和中文简繁体字符。
这个字库应该是开源字库或者商业授权字库,不应该有版权问题。
这样的字库目前已经很多了,比如站酷,阿里,谷歌都有提供。
这个文件本质上是一个索引文件,以此文件的每一个字为索引,在真正的字库文件中对应到真正的字模。
这个文件中除了需要的中文,也应该包括所有数字和英文大小写字母以及标点符号。
在Assets目录下创建Fonts目录,导入上面两个文件到此目录。
选择 Window > TextMeshPro > Font Asset Creator,在弹出的字体资产创建窗口中:
在窗口中点击Generate Font Atlas,等待生成完成。
点击“Save”保存生成的字体资产文件。此时如果你在Finder中查看,会发现其扩展名为.asset。
点击场景中TMP文本游戏对象,在属性窗口中的“Font Atlas”条目中选择你创建好的字体资产。此时你就可以在场景视图中看到中文了。
最后一步了,你要完成当Ruby和Jambi谈话时显示对话框。
对此,首先通过禁用来隐藏画板:
层级窗口中选择青蛙的子画板,属性窗口中顶部的复选框取消勾选。
为Jambi创建一个新的NonPlayerCharacter脚本。
在脚本中,定义两个公共变量:
一个浮点型变量,命名为displayTime,初始化为4,用来存放对话框显示了多长时间
一个游戏对象类型的变量,命名为dialogBox,用来存放画板游戏对象,以便启用/禁用它。
public float displayTime = 4.0f;
public GameObject dialogBox;
float timerDisplay;
void Start()
{
dialogBox.SetActive(false);
timerDisplay = -1.0f;
}
如果它大于0,则对话框在显示:这种情况下你应该不断减去Time.deltaTime来检查timerDisplay何时为0。此时就可以隐藏对话框了。
void Update()
{
if (timerDisplay >= 0)
{
timerDisplay -= Time.deltaTime;
if (timerDisplay < 0)
{
dialogBox.SetActive(false);
}
}
}
public void DisplayDialog()
{
timerDisplay = displayTime;
dialogBox.SetActive(true);
}
if (hit.collider != null)
{
NonPlayerCharacter character = hit.collider.GetComponent<NonPlayerCharacter>();
if (character != null)
{
character.DisplayDialog();
}
}
到这里你所做的是检查是否有碰撞,如果有,那么看看射线检测方向是否可以获取一个NPC脚本组件对象,如果脚本获取成功,就显示对话框。
别忘了给青蛙对象NPC脚本组件中的Dialog Box条目分配青蛙的子画板对象。
现在再试试让Ruby面向Jambi时按下X键。这次,对话框会出现并在4秒后消失(或者以你在属性窗口中设置的displayTime值为秒数)。
此时你的RubyController脚本、NonPlayerCharacter脚本应当如下:
(李大数:为节省篇幅,代码见原文)
这一节课中已经看到了射线检测在交互式应用中非常有用,因为他允许你在给定的方向上检测碰撞。
除了检测角色的前方有什么,射线检测根据你想制作的游戏类型还有很多用途。
举个例子,要检查主角和敌人之间是否有其它东西,你可以用这两个点之间的射线检测,如果返回一个hit对象,那么一定有一些物体在他们之间,所以敌人此时是看不见主角的。
本节核心是掌握三个要点。
第一个要点是“射线检测”的概念,用射线检测的Hit函数来测试命中哪些碰撞体,然后操作。
第二个要点是如何显示一个对话框
第三个要点是锚区的缩放如何影响子对象的大小
锚区用上下左右的规范化值来表示,当锚区收缩为一个点时就是“锚点”,此时上下相等,左右相等。
锚点状态时父对象的缩放不影响子对象的大小。
锚区状态时父对象的缩放会影响子对象的大小,此时子对象不再用(x,y,w,h)表示,而是用(l,r,t,b)来表示,这四个变量是和四条锚边的偏移像素值,向内为正,向外为负。此时子对象的位置不再由支点相对锚点的偏移给出,而是直接由四边给出。这种锚区方式用在主角的生命条上很方便。
原文链接
到目前为止,你的游戏一直是无声的。在本节课中,你会学到如何使用音频文件来播放一些背景音乐或者当Ruby拿到一个医疗包或者修好一个机器人的时候播放一个声音。
Unity声音系统由以下几部分组成:
音频剪辑
音频剪辑属于资源,如同纹理或者脚本。你可以从一个音频文件中导入音频剪辑,例如mp3,ogg和wav文件,它们存在于你的项目目录中。你可以在Audio目录下找到用于本节课的音频剪辑。
Audio LIstener(李大数注:从上下文来看,这里翻译为“音频听者”比较合适)
音频听者是一个组件,定义了“听者”位于场景的何处。这对于你想在左方/右方扬声器(或者某些装置中的后方/前方)播放更多的声音以便提供环绕玩家的声音时很有用。
缺省情况下,它位于摄像机上。因为玩家期望从相机位置听到声音,所以屏幕右侧的声音应该在右方扬声器播放。
如果你在层级图中点击摄像机,你可以看见它上面有一个Audio Listener组件。
即使你不使用特殊的声音,对于这个例子中的背景音乐,你仍可以使用居于游戏对象上的音频源,因为它可以播放声音。
让我们开始制作一些可以播放的背景音乐:
这有一点像在播放器中听音乐,音乐被设置为立体声。如果滑条全程3D,声音会在左右扬声器或多或少调整,依赖于音频源相对音频听者的位置。
由于本例子中你需要无论哪里都听到一样的音乐,确保滑条全程2D。你现在可以忽略其它属性。
小提示:如果你很好奇,点击右上角有个问号标记的图书图标来打开Unity手册。
当你需要一直播放的声音时分配一个声音剪辑是非常好用的。不过有时你需要在某事件发生时只播放一次声音,例如角色得到可以补血的物品时。
一种操作是创建一个新音频源,分配补血包音频并且不勾选Play On Awake选项,让它不要在游戏开始时播放。
之后在脚本中你可以在事件发生时播放音频剪辑。不过这需要为游戏里的每一个小声音都创建游戏对象和音频源。
与之相反,你应该使用PlayOneShot这个音频源函数。与Play不同,后者播放分配给音频源的音频剪辑,PlayOneShot把音频剪辑作为第一个参数并且只播放一次,使用音频源的所有设置和位置。
所以你可以添加一个音频源到我们的Ruby主角,并且使用那个音频源来播放所有游戏中的主角动作音效,例如拿到血包,扔出齿轮或者被击中。
注意:如果你在场景中添加音频源到Ruby而不是预制件模式,记得在属性窗口中应用并覆盖。
打开RubyController脚本,添加私有AudioSource变量,命名为audioSource,在Start函数中调用GetComponent获取音源对象并设置给此变量。
接着我们写一个函数PlaySound,它以一个音频剪辑为参数,调用音频源对象的PlayOneShot函数。
AudioSource audioSource;
void Start()
{
rigidbody2d = GetComponent<Rigidbody2D>();
animator = GetComponent<Animator>();
currentHealth = maxHealth;
audioSource= GetComponent<AudioSource>();
}
public void PlaySound(AudioClip clip)
{
audioSource.PlayOneShot(clip);
}
public class HealthCollectible : MonoBehaviour
{
public AudioClip collectedClip;
void OnTriggerEnter2D(Collider2D other)
{
RubyController controller = other.GetComponent<RubyController>();
if (controller != null)
{
if (controller.health < controller.maxHealth)
{
controller.ChangeHealth(1);
Destroy(gameObject);
controller.PlaySound(collectedClip);
}
}
}
}
为何使用Ruby对象上的Audio Source而非收集物品对象上的呢?,因为音频源是一个组件。当Ruby抓住收集物品时,收集物品对象被销毁,自然附着其上的音频源组件也被销毁,声音嘎然而止。
通过播放Ruby上的音频源,声音在收集物品销毁时就仍然能持续播放了。
现在检查你项目窗口中的血包预制件。一个新的名为Collected Clip的槽出现并可用,拖放Aduio 目录下名为Collectable的音频剪辑到槽中。
你现在可以进入播放模式,让Ruby抓取血包来听听音效 - 别忘了先让Ruby被机器人碰一下掉点血!
作为一个练习,你可以试着添加Ruby扔齿轮或者被敌人撞到的声音,你需要做的是:
原文链接
你已经深入学习了创建游戏所需要的每一步。你看过如何导入资源,写脚本,使用物理引擎,使用瓦片地图,创建粒子效果乃至添加音频。
既然你的游戏已经完成了,你将需要构建它来创建一个单独的应用,然后就可以上传到数字商店了。
接下来大家就能够玩这个游戏而无需安装Unity编辑器,同时你所有游戏的资源都在他们的设备中。
你用编辑器创建好的要分发给用户的应用程序叫做Player。在创建Player之前,让我们快速看一下Player设置。