项目的初衷是以项目形式串起unity各种零散知识,语言风格较详细(啰嗦)
先来看需要实现什么东西
构建卡牌对象
简单的UI系统
UI和场景的简单交互
打开unity,新建2D项目
左键点击Main Camera,在Inspector面板中点击Background修改背景颜色,同时将size修改为屏幕像素高度/100f/2),比如你的屏幕像素是1920x1080,那么size*就应该设置为5.4,这样屏幕上一个像素对应图像中一个像素(所谓Pixel-Perfect)
导入资源。在Project新建文件夹存放我们需要的资源,然后直接把文件(这个项目用到的全是图片)拖进Project面板里。我们新建的是2D项目,所以可以看到在Inspector面板里纹理类型是Sprite。需要注意的是Pixels Per Unit这一项,该数值为屏幕上的一个单元对应原图片的多少像素点,简单理解,该值越大,图片就显示得越小
(PS:导入图片时最好确保所有同一用途的图片大小以及Pixels Per Unit都保持一致,图片大小可以在点击图片后在Inspector面板右下角查看,如果大小不一建议在导入前用PhotoShop的裁剪工具处理,尤其是你素材是自己随便截下来的时候)
在Hierarchy窗口右键:2D Object->Sprite 新建一个空白物体,顺手在Inspector面板将他的Position改成(0,0,1)
(PS:由于Unity使用左手坐标系,xyz轴的正方向分别是右、上、前,离我们越远的物体Z值越大,因此个人习惯将摄像机的Z值设置为0,将其他物体Z值设置为1)
将图片拖拽到Sprite Renderer下Sprite处(下文简称为“槽”),完成“图片赋值”
但还没完!一张卡牌应该有卡背。在刚刚创建的对象下用同样的步骤新建一个子物体,将卡背图片拖拽,但这次将Position设置为(0,0,-0.1)此时的位置是与父物体的相对位置,为了保持神秘感,我们先将卡牌翻面,因此将Z值设为-0.1(理由见同上
是时候敲点代码了!在Project中Create一个脚本(希望你已经建好了一个用于存放脚本的文件夹),并将它拖给你的卡牌
// BasicCard.cs
public class BasicCard : MonoBehaviour
{
private GameObject Card_Back;
public void OnMouseDown()
{
if (Card_Back.activeSelf)
Card_Back.SetActive(false);
else
Card_Back.SetActive(true);
}
void Start()
{
// 查找子物体,获得卡背
Card_Back = transform.Find("Card_Back").gameObject;
}
}
transform.Find()函数能让你获取该物体下的子物体,是父子物体通信的有效手段,我们接下来还会遇到物体与物体间通信的问题
重写了鼠标响应事件函数之后,我们现在可以点击卡牌使它翻面了…吗?好像还不行,因为只有带有碰撞器组件的游戏物体才能对鼠标做出反应。于是我们给BasicCard添加一个Box Collider2D(点Add Component)
还差最后一步。我们不希望每次创建卡牌都要重复一次上述过程,因此可以用刚刚创建好的BasicCard创建一个预置体
将BasicCard从Hierarchy视图拖到Project视图中,你会发现Hierarchy视图中的箱子图标变蓝了。此时删除原来的BasicCard,对预置体没有影响
Unity有两种GUI系统,一种是直接模式(immedia mode),在每帧显示地发出绘制命令,完全基于代码,它的核心是OnGUI()方法,会在渲染完场景中所有物体后调用。它可以实现一些很简单的东西(比如FPS游戏中的准心),但缺点显而易见(顺便一提,可能是我匆匆忙忙学艺不精,感觉QT的UI逻辑就是这种,十分折磨)
所以我们选择另一种保留模式(retained mode),只需一次定义后系统自动每帧绘制,无需重新定义;而且能在编译器里工作,方便直观(最常用)
右键->UI->Canvas,我们创建了一个画布对象,之后所有的UI图像和操作都附加在上面。它缩放的很大,但不用害怕,因为场景中1个单位相当于UI上1个像素。你会发现自动创建了个EventSystem,暂时不管它
(P.S:Canvas组件下的Render Mode选项有三个模式,默认Overlay会将UI渲染在摄像机顶部;Camera则在前者基础上加上可以进行透视效果的旋转;World Space则将Canvas对象放置场景中,可以用该选项提高UI的浸入感,比如光环中将武器剩余子弹数显示在武器上)
新建一个Text和Button对象(自动变成Canvas的子物体),自行设置图片字体大小之类的(按照设置sprite的方法直接拖就行),之后分别放在右上角和左上角。为了在游戏画面缩放时能自适应屏幕大小,我们分别设置好对应的锚点(相对位置)
在整合UI和场景之前,我们需要一些准备工作。我们需要一个SenceController帮我们自动化生成卡牌,以代替目前在inspect面板手动添加的方式
新建一个空物体,再写一个脚本,命名为SceneController
// SceneController.cs
public class SceneController : MonoBehaviour
{
[SerializeField] private GameObject CardPrefab;
[SerializeField] private int MaxCardsNumber = 8; // 最大总卡牌数
[SerializeField] private int colNum = 4; // 每行最大卡牌数
[SerializeField] private float offset = 2.5f; // 相邻卡牌间隔
// 卡牌初始位置
private Vector3 firstPos = new Vector3(-2.5f, -2.5f, 0.0f);
private int CardsNum = 0; // 记录卡牌数
void createCards()
{
while (CardsNum < MaxCardsNumber)
{
GameObject newCard = Instantiate(CardPrefab);
// 控制卡牌按间隔分布
if (CardsNum >= colNum)
newCard.transform.position =
new Vector3((CardsNum - colNum) * offset, offset * 2, 1) + firstPos;
else
newCard.transform.position =
new Vector3(CardsNum * offset, 0, 1) + firstPos;
++CardsNum;
}
}
void Start()
{
createCards();
}
}
SerializeField意为序列化,简单理解就是能在inspect面板里面修改脚本中的属性(注意当你序列化变量之后会以inspect上的值为准,此时你光修改代码是无法改变变量的值的,必须在inspect作出修改)当然,将变量属性设置为public能达到同样的效果,但这就破坏了所谓封装性。所以SerializeField是实用性和封装性之间的一个小小妥协
另一个新东西是Instantiate()方法。该方法返回一个原对象的克隆值,新对象的激活状态和原对象保持一致。我们用刚刚创建好的卡牌预置体来生成新卡牌
将预置体拖入“组件槽”中,运行游戏,如无意外你会看到八张整齐排列的卡牌:
好像还不戳,但你逐一点开之后发现八张都是一样的(和预制体保持一致)。想生成不同的卡牌有两种办法:
一、将所有不同种类的卡牌制成预置体
二、不预设图片,生成卡牌对象时在动态“赋值”
这里我们选择第二种方法,原因主要是不同的卡牌只有图片一个属性是不一样的,可以通过SceneController动态生成一个数组来给不同的卡牌“赋值”;其次生成不同预置体后还要一个个拖到“槽”上“赋值”,十分麻烦(其实可以用GameObject数组来管理优化,不过现阶段没必要这么麻烦,如无必要勿增实体)
(P.S:不妨设想什么情况下第一种方法是更好的选择)
首先确定需求。我们需要一个存放Sprite的数组用于“赋值”(总比GameObject数组好,不是吗);接着修改代码,让其能随机地生成卡牌(的封面)
我们首先将卡牌预置体中的Sprite属性重新设置为None;接着修改脚本
// SceneController.cs
// 之前已存在且无需修改的部分用...表示
public class SceneController : MonoBehaviour
{ ...
[SerializeField] private Sprite[] Card_Front;
} ...
保存后看inspect面板,在size一行输入你想要的大小,再将将对应sprite拖到“槽”上赋值
还需要稍微修改生成卡牌的代码,产生0—3的随机数给图片赋值
// SceneController.cs
void createCards()
{
while (CardsNum < MaxCardsNumber)
{
...
if (CardsNum >= colNum)
...
else
...
newCard.GetComponent().sprite = Card_Front[Random.Range(0,4)];
...
}
}
GetComponent<>()可以获取本游戏物体上的组件;Random.Range(int min,int max)则返回一个范围内的随机int(包括min但不包括max)
(P.S.:浮点数重载的Random.Range(float min,float max)的范围是包括min也包括max,使用时要注意)
unity中父子物体间通信可以用transform.Find()解决,那不同物体间的通信呢?unity其实提供了自带的消息系统(还记得我们创建Canvas对象时自动创建的那个EventSystem吗)其核心方法是FindObjectOfType().SendMessage("methodName
",参数),FindObjectOfType()可以返回第一个类型为 T 的已加载的激活对象的游戏物体;SendMessage()则调用此游戏对象中的每个 MonoBehaviour 上名为 methodName
的方法,同时可以传递参数;结合这两个方法可以实现不同物体间(单对单)通信。优点是简单,同时能保证通信必定成功(计网中的面向连接可靠通信)因为发出的消息没被接受会报错…
缺点是只能实现单对单通信,虽然有FindObjectsOfType()版本能返回一个符合条件的对象数组,但挺麻烦(要写很多个SendMessage());另外FindObjectOfType().的效率很低,不建议每帧都使用。
但还有另一种选择,观察者模式。有通信需求的游戏物体挂载一个监听器,消息发出者向全体监听者广播一条消息,监听者发现这条消息是针对自己的,就做出响应(如果不是,那么就丢弃不管)这样就轻松实现了一对多通信
为了教学目的,这个例子中我们用第一种方法(其实是因为简单)
有来有回才算是交互!我们需要让UI响应场景的变化,同时也能通过在UI层的操作改变场景
先看需求:我们希望Text能显示场上的卡牌数;按Button可以生成卡牌
先实现第一部分:场景->UI
在Canvas下新建一个脚本,命名为UIController
// UIController.cs
public class UIController : MonoBehaviour
{
void showCardsNum(int num)
{
// 应该不陌生了
Text t = transform.Find("Text").GetComponent();
t.text = "目前场上卡牌数:" + num.ToString();
}
}
然后在SceneController(下面简称SCer)给UIController(简称UICer)发信息(谁取的名字啊,又臭又长.jpg
// SceneController.cs
void createCards()
{
while (CardsNum < MaxCardsNumber)
{
...
// unity自带的消息系统
FindObjectOfType
再实现第二部分:UI->场景
button想发送消息十分简单,点开button的inspect面板,发现有一个On Click()条目,左上角的“槽”设置响应模式(编译器下或是游戏运行时),点右下角的小加号,将SceneController对象拖到右下角的“槽”;右上角的“槽”可以选择调用该游戏物体下的组件中的方法,这里我们选SceneController->SendMessage(string),接着在文本输入域内写你想调用的方法名,我们写createCards
(P.S:OnClick()是按钮组件暴露给外部的唯一事件,但我们也有对所有类型的游戏物体都适用的,响应各种不同交互方式的办法,答案是使用EventTrigger组件;添加组件后点Add New Event Type,选择你想响应的活动。接下来的操作就和上面一样了)
打开SCer,将createCards()从Start()删去
Congratulations!现在能看到完整效果了
回顾一下我们实现了什么东西
构建卡牌对象:有不同封面和默认卡背,能自动生成的卡牌
简单的UI系统:按钮和文本框
UI和场景的简单交互:按钮生成卡牌;文本框显示卡牌数
这节涉及的前置知识较多,代码量和操作难度其实不大,但有了基本框架后,我们可以做一些比较cool的东西了