Unity学习笔记2 简易2D横版RPG游戏制作(二)

十二、敌人受攻击时的闪烁和Player的生命值的修正

上一篇中,我们利用Controller2D中的IEnumerator TakenDamage接口,使得我们的Player受到攻击时会进行闪烁,我们同样地也希望在我们的敌人身上可以实现相同的效果。所以我们现在需要复制Controller2D脚本里面的两个内容到我们的Enemy2D脚本里面去:

第一个内容:

//显示角色当前正受到攻击
	float takenDamage = 0.2f;

第二个内容:

public IEnumerator TakenDamage(){
		renderer.enabled = false;
		yield return new WaitForSeconds(takenDamage);
		renderer.enabled = true;
		yield return new WaitForSeconds(takenDamage);
		renderer.enabled = false;
		yield return new WaitForSeconds(takenDamage);
		renderer.enabled = true;
		yield return new WaitForSeconds(takenDamage);
		renderer.enabled = false;
		yield return new WaitForSeconds(takenDamage);
		renderer.enabled = true;
		yield return new WaitForSeconds(takenDamage);
	} 

接下来我们需要对Bullet脚本进行处理,使其在碰撞到我们的敌人时,向敌人发送一个闪烁的信号:(修改内容如下)

void OnTriggerEnter(Collider other){
		if (other.gameObject.tag == "Enemy") {
			Destroy(gameObject);
			other.gameObject.SendMessage("EnemyDamaged",damageValue,SendMessageOptions.DontRequireReceiver);
			other.gameObject.SendMessage("TakenDamage",SendMessageOptions.DontRequireReceiver);
		}

		if (other.gameObject.tag == "LevelObjects") {
			Destroy(gameObject);
		}
	}

其实就是增加了一句:

other.gameObject.SendMessage("TakenDamage",SendMessageOptions.DontRequireReceiver);

解决了敌人的闪烁问题之后,我们接下来要处理另外一个问题了。这个问题就是,我们的角色每消灭一些敌人就会增加经验,而经验的增加会自动补充HP,这个有点不对头和不合理,我们升级的时候增加的应该是最大HP,在每个等级下,我们应该设置一个当前的最大HP,不能无限制地增加HP才对,所以呢,我们需要将原来的GameManager脚本里面的playersHealth变量改名为curHealth,并且增加一个maxHealth变量,同时设置为3,将原本的playersHealth++修改为maxHealth++,这样的话,消灭敌人只会增加你的最大HP,而不会增加你当前的HP,至于升级之后是不是要让HP全满呢,这个功能很容易实现,我暂时不想加进去。
接下来我们就需要考虑下一个问题了,那就是角色的战斗力必须随着等级的提升而得到提升才比较合理。不能每次攻击都只扣敌人一滴血啊。而且敌人只有3到6滴血的设定不太合理,其实反正敌人的血是不显示出来的,完全可以设置一些大一点的数值,比如100,500之类的,这个就到后面有需要再改吧。现在暂时先不动。

十三、补血药的设置

在这一讲里面,首先要处理一个问题:我们的Player应该是有战斗力的,而不是每次只向敌人发送1滴的伤害值。利用某某++的方法可以很容易实现升级时增加战斗力,这个就不说了。关键是怎么弄成用我们的战斗力去减敌人的血值。首先,我们在GameManager脚本里面添加这么一行:

static public int bulletDamage = 1;

然后把我们的curHealth,也就是当前HP的值也改成static public。这里顺便补充一下,static在c#中的作用。

静态分配的,有两种情况:
 1. 用在类里的属性、方法前面,这样的静态属性与方法不需要创建实例就能访问,
  通过类名或对象名都能访问它,静态属性、方法只有“一份”:即如果一个类新建有N个
  对象,这N 个对象只有同一个静态属性与方法;
2.  方法内部的静态变量:
   方法内部的静态变量,执行完静态变量值不消失,再次执行此对象的方法时,值仍存在,
   它不是在栈中分配的,是在静态区分析的, 这是与局部变量最大的区别;
如果这个说得不具体的话,那么可以看一下下面这个,红黑联盟里面讲的,非常形象,保证一看马上就明白了:(我也是在看视频教程的过程中碰到了static不懂,然后看下面这个理解透彻的)

提起static,一般理解为静态、全局。
何为static?我理解的static属于程序的直属单位,而非static就是非直属单位。
举一个非常常见的例子,中国有4个直辖市,北京、上海、天津、重庆,这些相当于static,而广州、南京、杭州等就是非static,中央可以直接管理北京、上海、天津、重庆,而广州、南京、杭州应由各省政府管理,Main方法可以直接调用static,而调用非static需要实例化。

class City() 
{ 
    //4个直辖市static 静态全局类型 
    public static void Beijing(){} 
    public static  void ShangHai(){} 
    public static  void Tianjin(){} 
    public static  void Chongqing(){} 
    //其他城市 非静态 
    public void Guangzhou(){} 
    public void Nanjing(){}  
} 
void Main() 
{ 
    //调用static类型的方法 
    City.Beijing();//调用北京 
    City.Shanghai();//调用上海 
 
    //调用非static类型的方法 
    //没有直接调用权利,必须先实例化 
    City chengShi=new City(); 
    chengShi.Guangzhou();//调用广州 
} 
讲的形象就达到目的了,为刚开始学习编程的同学加把劲儿。

原链接:http://www.2cto.com/kf/201209/152853.html

好啦,然后我们继续。接着我们打开我们的Bullet脚本,把里面的内容修改成这样:

using UnityEngine;
using System.Collections;

public class Bullet : MonoBehaviour {

	//用于碰撞时摧毁两个物体
	void OnTriggerEnter(Collider other){
		if (other.gameObject.tag == "Enemy") {
			Destroy(gameObject);
			other.gameObject.SendMessage("EnemyDamaged",GameManager.bulletDamage,SendMessageOptions.DontRequireReceiver);
			other.gameObject.SendMessage("TakenDamage",SendMessageOptions.DontRequireReceiver);
		}

		if (other.gameObject.tag == "LevelObjects") {
			Destroy(gameObject);
		}
	}

	void FixedUpdate(){
		Destroy (gameObject, 1.25f);
	}
}
其实上面的内容只是将

other.gameObject.SendMessage("EnemyDamaged",GameManager.bulletDamage,SendMessageOptions.DontRequireReceiver);

这一行进行了处理,我们原本的那个damageValue变量已经被删掉,然后替换成我们GameManager里面的bulletDamage,因为我们的这个bulletDamage已经修改为static,所以现在可以这样调用了。以后如果想要升级增加角色的杀伤力的话,就容易多了。

接下来我们想要在运行游戏中按C的时候顺便显示我们的战斗力,这个太简单了,只需要对GameManager里面做一点点修改:

if (playerStats) {
			statsDisplay.text = "等级:" + level + "\n经验:" + curEXP + "/" + maxEXP + "\n攻击力:" + bulletDamage;
		} 

这一部分大家可以随便修改,你想显示什么就修改什么。至于在画面中的位置,可以调整GUIStats的transform,这个就不再赘述。

我稍微做了点修改。

接下来我们需要在场景中新增一个quad,去掉Mesh Collider,加上Box Collider,然后Box Collider的size全部改成1,transform里面的position的z值别忘了设成0,名字随便起,是用来做成补血药的,我命名为HealthPotion,然后为它增加一个同名的tag。为了区分,我顺便弄了一个同名的material扔上去,将颜色调成粉红色。(呵呵……感觉是毒药而不是血药啊……)

接着我们打开上次弄的那个StickToPlatform脚本,把里面的东西复制到我们的Controller2D上面,其实因为两个脚本都是绑定在Player身上,就没必要弄两个脚本了,直接合并成一个就成了。

然后我们再增加一段小代码来使得我们的Player碰到带有HealthPotion的物体时,将这个物体摧毁并且curHealth值加1。

void OnTriggerStay(Collider other){
		if (other.tag == "Platform") {
			this.transform.parent = other.transform;
		}
	}
	
	void OnTriggerExit(Collider other){
		if (other.tag == "Platform") {
			this.transform.parent = null;
		}
	}

	void OnTriggerEnter(Collider other){
		if (other.tag == "HealthPotion") {
			GameManager.curHealth++;
			Destroy(other.gameObject);
		}
	}

很简单吧,现在补血药就已经做好了。想要做出补多少血的补血药都已经不是什么问题了。想要做出补蓝、增加战斗力什么的补血药,也不是什么问题了哈哈。

有个地方需要大家注意一下,那就是curHealth必须加上static,否则会出现An object reference is required to access non-static member这样的报错显示。在全局静态函数里面是不可以使用非全局静态变量的。

吃药前(注意左上角两个红心)

吃药后,哈哈,疗程短见效快,一粒补一滴血~

关于Box Collider的范围的问题,可以参考上一篇讲到的Player的Character Controller组件的问题,所以我们可以顺便把药物的Box Collider的size的X值改为1.3。

十四、游戏暂停和游戏存档

游戏存档看似简单,不过也是个比较蛋疼的问题。我们打开GameManager脚本:
首先,我们增加一个布尔值:

//用于暂停的布尔值
	bool pauseMenu;

然后我们增添以下代码:

if (pauseMenu) {
		if(GUI.Button(new Rect(Screen.width*.25f,Screen.height*.4f,Screen.width*.5f,Screen.height*.1f),"保存游戏")){
			print ("已保存");
			PlayerPrefs.SetInt("Player Level",level);
			PlayerPrefs.SetInt("Player EXP",curEXP);
		}
		if(GUI.Button(new Rect(Screen.width*.25f,Screen.height*.6f,Screen.width*.5f,Screen.height*.1f),"显示保存的数据")){
			print ("显示保存的数据");
			print("当前等级:"+ PlayerPrefs.GetInt("Player Level"));
			print("当前经验:"+ PlayerPrefs.GetInt("Player EXP"));
		}
	}

然后保存脚本。

现在我们在运行游戏的过程中按P键,就可以调出一个这样的画面:


当我们点击保存游戏时,就会保存相应的数据。我们可以先杀掉一只小怪,然后保存,然后重新运行,然后点击“显示保存的数据”,这个时候就会看到:


但是这个时候有两个问题,第一个问题是,虽然在这里我们可以看到保存的数据,但是实际上在游戏里面,如果我们按C键查看角色的属性时,发现经验值并没有保存下来。第二个问题是,我们的存档还不知道怎么删除……

接下来马上解决这个问题:

十五、载入游戏和删除存档

好了,我们继续修改我们的GameManager脚本,首先我们要增加一个void Awake()函数,这个东西和void Start()有什么不同呢?我顺便摘录了一段网上搬过来的笔记:

这是博客园的一篇介绍的原链接:http://www.cnblogs.com/xpvincent/p/3178042.html

Unity3D初学者经常把Awake和Start混淆。

简单说明一下,Awake在MonoBehavior创建后就立刻调用,Start将在MonoBehavior创建后在该帧Update之前,在该Monobehavior.enabled == true的情况下执行。

[javascript] view plaincopy
 
  1. void Awake (){  
  2. }       
  3. //初始化函数,在游戏开始时系统自动调用。一般用来创建变量之类的东西。  
  4.   
  5. void Start(){  
  6. }  
  7. //初始化函数,在所有Awake函数运行完之后(一般是这样,但不一定),在所有Update函数前系统自动条用。一般用来给变量赋值。  

我们通常书写的脚本,并不会定义[ExecuteInEditMode]这个Attribute,所以Awake和Start都只有在Runtime中才会执行。

例1:

[javascript] view plaincopy
 
  1. public class Test : MonoBehaviour {  
  2.     void Awake () {  
  3.         Debug.Log("Awake");  
  4.         enabled = false;  
  5.     }  
  6.    
  7.     void Start () {  
  8.         Debug.Log("Start");  
  9.     }  
  10. }  

以上代码,在Awake中我们调用了enabled = false; 禁止了这个MonoBehavior的update。由于Start, Update, PostUpdate等属于runtime行为的一部分,这段代码将使Start不会被调用到。

在游戏过程中,若有另外一组代码有如下调用:

[javascript] view plaincopy
 
  1. Test test = go.GetComponent<Test>();  
  2. test.enabled = true;  

这个时候,若该MonoBehavior之前并没有触发过Start函数,将会在这段代码执行后触发。

例2:

player.cs

[javascript] view plaincopy
 
  1. private Transform handAnchor = null;  
  2. void Awake () { handAnchor = transform.Find("hand_anchor"); }  
  3. // void Start () { handAnchor = transform.Find("hand_anchor"); }  
  4. void GetWeapon ( GameObject go ) {  
  5.     if ( handAnchor == null ) {  
  6.         Debug.LogError("handAnchor is null");  
  7.         return;  
  8.     }  
  9.     go.transform.parent = handAnchor;  
  10. }  

other.cs

[javascript] view plaincopy
 
  1. ...  
  2. GameObject go = new GameObject("player");  
  3. player pl = go.AddComponent<player>(); // Awake invoke right after this!  
  4. pl.GetWeapon(weaponGO);  
  5. ...  

以上代码中,我们在player Awake的时候去为handAnchor赋值。如果我们将这步操作放在Start里,那么在other.cs中,当执行GetWeapon的时候就会出现handAnchor是null reference.

总结:我们尽量将其他Object的reference设置等事情放在Awake处理。然后将这些reference的Object的赋值设置放在Start()中来完成。
当MonoBehavior有定义[ExecuteInEditMode]时

当我们为MonoBehavior定义了[ExecuteInEditMode]后,我们还需要关心Awake和Start在编辑器中的执行状况。

    当该MonoBehavior在编辑器中被赋于给GameObject的时候,Awake, Start 将被执行。
    当Play按钮被按下游戏开始以后,Awake, Start 将被执行。
    当Play按钮停止后,Awake, Start将再次被执行。
    当在编辑器中打开包含有该MonoBehavior的场景的时候,Awake, Start将被执行。

值得注意的是,不要用这种方式来设定一些临时变量的存储(private, protected)。因为一旦我们触发Unity3D的代码编译,这些变量所存储的内容将被清为默认值。

下面再来看看Unity圣典中的解释。

 Awake()

当一个脚本实例被载入时Awake被调用。

Awake用于在游戏开始之前初始化变量或游戏状态。在脚本整个生命周期内它仅被调用一次.Awake在所有对象被初始化之后调用,所以你可以安全的与其他对象对话或用诸如 GameObject.FindWithTag 这样的函数搜索它们。每个游戏物体上的Awke以随机的顺序被调用。因此,你应该用Awake来设置脚本间的引用,并用Start来传递信息。Awake总是在Start之前被调用。它不能用来执行协同程序。

Start()

Start仅在Update函数第一次被调用前调用。Start在behaviour的生命周期中只被调用一次。它和Awake的不同是Start只在脚本实例被启用时调用。
你可以按需调整延迟初始化代码。Awake总是在Start之前执行。这允许你协调初始化顺序。

好了,这样就清楚为什么要用Awake()函数了吧。

接下来我们就这样弄,首先我们设置一个int saved,让它等于零。(如果不赋值的话,它也会默认等于零)

//用于判断是否是否保存
	int saved = 0;

接下来我们就写下这么一个Awake函数:

void Awake(){
	saved = PlayerPrefs.GetInt ("Game Saved");
 	    if (saved == 1) {
			curEXP = PlayerPrefs.GetInt ("Player EXP");
			level = PlayerPrefs.GetInt ("Player Level");
			maxEXP = level * 50;
			maxHealth = level + 2;
			curHealth = maxHealth;
	   } 
	}

在运行这个Awake函数的时候,就会让saved先获取我们保存的值。(等一下在下面存档的那部分脚本里面,我们会保存先把saved赋值为1,再进行保存),因为PlayerPrefs不能保存布尔值,所以我们用一个int的0和1来代替就行了,一样的。如果我们的游戏没有存档的话,saved读取不到任何数据,就会默认为零,那么就相当于不会接下去读取我们保存的数据了,反之,如果读取到了1,就相当于读取到了“已经有保存的数据”的情况,就需要继续执行。

在保存数据部分,我是这样弄的:

if (pauseMenu) {
		//“保存游戏”按钮
		if(GUI.Button(new Rect(Screen.width*.25f,Screen.height*.4f,Screen.width*.5f,Screen.height*.1f),"保存游戏")){
			print ("已保存");
			saved = 1;
			PlayerPrefs.SetInt("Player Level",level);
			PlayerPrefs.SetInt("Player EXP",curEXP);
			PlayerPrefs.SetInt("Game Saved",saved);
		}
		//“显示保存的数据”按钮
		if(GUI.Button(new Rect(Screen.width*.25f,Screen.height*.6f,Screen.width*.5f,Screen.height*.1f),"显示保存的数据")){
			print ("显示保存的数据");
			print("当前等级:"+ PlayerPrefs.GetInt("Player Level"));
			print("当前经验:"+ PlayerPrefs.GetInt("Player EXP"));
			print("是否保存:"+ PlayerPrefs.GetInt("Game Saved"));
		}
	}

我们可以看到,在保存之前,我们先将saved赋值为1,然后再保存,这样的话,当我们重新载入游戏时,就会进行一个是否保存了游戏的判断。

至于下面那个“显示保存的数据”的按钮功能,只是我用来debug.log的,没什么用,纯属调试,可以无视。

可能有人会说,保存游戏就直接保存,然后直接读取数据不就行了,为什么还要弄一个saved来判断呢?我一开始也是没有弄这个玩意的,后来发现了一个问题,在这里解释一下:假设我们现在删除了游戏存档,而没有这个用来判断是否有存档的saved值的话,那么我们的脚本自然就不管三七二十一,你没有存档它也会当成你是有存档的,这样会出现什么问题呢?这样的话,我们的curEXP = PlayerPrefs.GetInt ("Player EXP");和level = PlayerPrefs.GetInt ("Player Level");这两句话就会得不到任何数据,那么就会默认为零,那么,下面的maxEXP = level * 50;还有maxHealth = level + 2;这两句的计算就肯定会出问题了。maxEXP会变成0,而maxHealth会变成2,最大经验值变成零也就算了,我们的血值还从3变成了2,这不是坑爹么……哈哈,所以,这下子明白为什么要做一个是否saved的判断了吧。对于我这样的小游戏来说,就已经需要制作一个是否saved的判断了,对于需要保存大量数据的大游戏来说那就更是如此。希望大家如果是制作RPG类型的游戏的话,也可以养成类似的习惯。

接着要解决删档的问题。这个也是RPG游戏的一个重点内容。我们打开MainMenu脚本,然后在if (showGUIOutline)里面加入以下内容:

//“删除存档”按钮
if (GUI.Button (new Rect (Screen.width * guiPlacementX3, Screen.height * guiPlacementY3, Screen.width * .5f, Screen.height * .1f), 
"删除存档")) {
PlayerPrefs.DeleteAll();
print ("已删除存档");
}

其实主要就是添加一个PlayerPrefs.DeleteAll();而已,没什么复杂的。

当然,为了方便我们在窗口中调整GUI的位置,我们也增加了guiPlacementX3还有guiPlacementY3这两个public的float值。这里就没必要贴出来了。
接着在Mainmenu场景里面的Maincamera上面调整好三个GUI按钮的位置:


接着我们来测试运行一下,我们进入Mainmenu场景,然后运行,点击“删除存档”按钮:


我们会看到print出了一句已删除存档的提示。
接着我们载入游戏,来到我们那个丑丑的游戏场景,然后按C,查看一下我们当前的各项状态。


现在我们还是初始状态,我们去随便刷掉两只怪,然后顺便去作死一下,扣掉一滴血。然后按P键调出保存菜单。


现在我已经按了保存,然后我按了“显示保存的数据”按钮,现在我们可以从右边的Console列表里面看到我们保存的数据。

好了,我们退出,重新进来~,再查看一下:


细心一点的朋友应该注意到,这次稍微有了点不同。那就是我又恢复成3滴血了。这是因为我们重新加载的时候,curHealth会变成当前等级的maxHealth,所以我们重新载入之后不是2滴血,而是3滴血。

现在我们重新进入Mainmenu场景,然后删除存档,再重新进来一遍:


可以看到,我们的角色的全部资料都清零了。(右手边的Console显示了我刚刚有进行“删除存档”按钮的点击操作,没有造假,哈哈)

好了,现在我们已经解决了角色存档的问题。这部分可能自己实际操作的时候会碰到一些问题,需要大家多做几次,特别是不同的游戏,情况肯定不一样,这个没办法有统一的标准,这里只是提供一个思路。

十六、自动存档

前面我们提到了存档的方法了。有些游戏是即时存档的,就是在Update每一帧都进行一次存档的操作,对于小游戏来说,这种做法无可厚非,但是对于类似宠物小精灵这种有庞大数据的游戏来讲,即时存档是不太可行的。也许有其他优化的方法,比如在独立游戏Terraria中,它就是支持大数据即时存档的,目前我尚不知道这种方法要如何实现。

下面,我们可以利用类似技能冷却的原理,设置一个定时自动存档器,比如说每隔五秒钟或者十秒钟自行存档一次,为了让玩家知道有自动存档的情况,我们可以调用一些GUI来显示(我这里就直接print了)。同理,我们也可以在场景中布置一些特殊物体,进行自动的存档,比如说某一个关卡末尾的大门,我们的Player碰到大门之后就会跳到下一个关卡,同时自动保存我们Player身上的全部数据。(至于这个场景跳转门或者说是位置跳转门能不能双向跳动,这个就要看你的游戏是怎么布置的了。)

废话不多说,马上开始:

首先我们需要在GameManager脚本里面创建一个叫做SaveGame()的函数,然后改为public类型(为什么要这样做后面会解释),然后把我们前面

在if(pauseMenu)里面的部分内容移到这个函数里面去:

public void SaveGame(){
		saved = 1;
		PlayerPrefs.SetInt("Player Level",level);
		PlayerPrefs.SetInt("Player EXP",curEXP);
		PlayerPrefs.SetInt("Game Saved",saved);
		print ("已保存");
	}

只有加上public,才能在外部进行调用嘛。好了,现在我们增加下面的内容:

if (other.tag == "Door") {
		string thisLevel = Application.loadedLevelName;
		int intThisLevel = int.Parse(thisLevel);
		int intNextLevel = intThisLevel+1;
		string nextLevel = intNextLevel.ToString();
		Application.LoadLevel(nextLevel);
	}

想要跳到下一个关卡有个很简单的函数可以使用,就是Application.LoadLevel(),可是有个问题,就是如果我们直接选择加载某一个关卡的话,我们这个脚本就不能重复利用了。也就是说,我们想要做成一个只要一碰到就会自动跳转到下一关的功能的脚本,这样就不必在每一个关卡里面都来写一个特定的脚本。首先,我们用string thisLevel = Application.loadedLevelName;这句话获取当前关卡的名字,我们之前将关卡命名为Scene1,现在直接改成1就行了。这样的话,我们就相当于得到了关卡的序列号。理论上来讲,我们只需要Application.LoadLevel(thisLevel+1);就应该是可以跳转到下一个关卡的了。如果这样的话那就很方便了。可是实际上有个很大的问题,那就是关卡的名字是字符串,字符串可不能直接做加减法的,所以我们需要将字符串强制转换为int类型。C#里面有Convert.ToInt32的转换方法,但是我在unity里面没办法使用(难道是我的打开方式不对?)所以我采用了另一种方法,也就是上面脚本的int intThisLevel = int.Parse(thisLevel);这样,我们获取当前的字符串名称1就会变成整数1(为了实现这个功能,我们必须将除了Mainmenu关卡之外的其他关卡全部命名为阿拉伯数字名称,即1、2、3……)然后我们对这个整数1进行加1的处理,即:int intNextLevel = intThisLevel+1;接着,我们将得到的这个新的整数重新转换为字符串string nextLevel = intNextLevel.ToString();到这里,加载下一个关卡的任务就完成了,最后我们加上这么一句:Application.LoadLevel(nextLevel);即可跳转到下一关卡。

关于强制类型转换的,可以参考这个:http://www.360doc.com/content/10/0907/16/2660674_51891839.shtml

C#,int转成string,string转成int

1,int转成string
用toString 
或者Convert.toString()如下 

例如:
int varInt = 1; 
string varString = Convert.ToString(varInt); 
string varString2 = varInt.ToString();

2,string转成int
如果确定字符串中是可以转成数字的字符,可以用int.Parse(string s),该语句返回的是转换得到的int值;
如果不能确定字符串是否可以转成数字,可以用int.TryParse(string s, out int result),该语句返回的是bool值,指示转换操作是否成功,参数result是存放转换结果的变量。

例如:
string str = string.Empty;
str = "123";
int result=int.Parse(str);

string str = string.Empty;
str = "xyz";
int result;
int.TryParse(str, out result);


接下来处理下一个问题,我们在刚刚的if (other.tag == "Door") {}里面插入一句gameManager.SaveGame();当然,在此之前,我们需要在这个脚本里面加上这么一行:

//引用GameManager
	public GameManager gameManager;

这个应该很好理解,就不再赘述了。使用public的原因是为了在外部进行拖拽操作,如果不将其设置为public的话,就会出现NullReferenceException: Object reference not set to an instance of an object字样的报错,这个我们在上一篇学习笔记里面已经有提到过了。出现这个的原因是因为我们此处引用了GameManager里面的一个SaveGame()函数,但是unity并不知道你到底在用哪个GameManager,所以会报Null,最简单的解决方法就是拖拽大法,也可以用GetComponent<>的方法。

看下面,这个是我的Player身上的Controller2D脚本的设置,我已经把GameManager物体拖放到相应位置了。


刚开始的时候,原作者并没有使用这个方法,而是直接复制了Bullet脚本里面的other.gameObject.SendMessage("TakenDamage",SendMessageOptions.DontRequireReceiver);这句进行修改。现在我们顺便来想想为什么不可以采用这种方法。因为这种方法是对着other.gameObject发送信号,而我们是对着自己发送信号啊。

可能有人会说,那还不简单,我们直接把other.gameObject.删掉不就可以了吗?原视频的作者也试了一次,不行。但是他没解释。我想了一段时间,终于明白了。因为SendMessage不可以把内容发送给自己这个脚本。发送给同个物体身上的不同脚本还是行得通的,但是自己发送给自己就不行了。所以,这种方法是行不通的,就只能用上面提到的那种方法了。

现在我们已经实现了场景跳转和按键存档功能,接下来就是自动存档功能了。我们在GameManager脚本的Update函数里面进行如下增添:

//自动存档功能
	autoSaveTimer += Time.deltaTime;
	if (autoSaveTimer >= 1000f) {
	SaveGame();	
	print ("保存啦~~");
	autoSaveTimer =0;
	}

上面的autoSaveTimer是自定义的一个int变量,我顺便设置成public,方便在外面修改。我将它改为10,也就是每隔10秒就会自动保存一次,前面我们将保存的功能整合成一个SaveGame()函数就是为了这个目的。那句print(“保存啦~~”)只是用来卖萌和提示自己的,嘿嘿~

每次保存之后autoSaveTimer这个自动保存的计时器就会清零,然后重新进入下一轮的计时,这样就可以实现循环保存的功效了。因为我们的游戏数据比较小,所以可以每隔一段时间就自动保存一次,大数据的游戏就不建议这样弄了。

这一讲很长,也涉及到了很多问题。所以我花了很长时间整理。如果大家有什么不太清楚的话,最好多看几遍,然后自己再试着操作一下试试。当然,这个是针对新手说的,各路大神可以无视哈。

十七、关卡选择

在这一讲里面,我们首先创建一个新的场景(为了方便测试,我把上一次弄的场景2直接重命名为场景LevelSelect,你要新建一个当然也没问题,这个不是重点,哈哈),这个场景的作用就是在进入时可以用来选择进入各个关卡的。很多横版的闯关游戏由于关卡比较多,所以在游戏开始的时候都会有这样的选择画面,让玩家自行选择这次要玩哪一关,就不必要每次都从第一关开始玩起了。而且由于Unity在游戏保存这一块非常给力,所以我们完全不需要担心数据丢失方面的问题。

接着我们先点击进入我们的Mainmenu场景,在摄像机的MainMenu脚本里面,把Level里面原本的Scene1改成现在的LevelSelect,这样我们就可以通过点击按钮直接跳转到我们现在要用到的这个LevelSelect场景了。


接着进入这个场景,然后创建一个同名脚本扔到摄像机上面,然后我们开始编辑:

public class LevelSelect : MonoBehaviour {
	int sw = Screen.width;
	int sh = Screen.height;
        public string Level;

	void OnGUI(){
		//进入第一关的按钮
		if (GUI.Button (new Rect (0, 0, sw * .5f, sh), "Level: 1")) {
            Application.LoadLevel(Level);
		}
	}
}

内容很简单,我就不做解释了。前面两个int是因为觉得打screen.width和screen.height很麻烦,所以才那样弄的,至于那个public string Level,之前介绍过了就不再解释为什么要这样做了。我这里只是随便弄了一个出来,大家可以在这个LevelSelect的脚本里面设置一堆不同的按钮,每个按钮连接到你的各个不同关卡里面去,这样就达到了关卡选择的效果啦。

接下来看看我们之前的Controller2D脚本,我们在这个脚本里面有这么一段:

void OnTriggerEnter(Collider other){
		if (other.tag == "HealthPotion") {
			GameManager.curHealth++;
			Destroy(other.gameObject);
		}

		if (other.tag == "Door") {
			gameManager.SaveGame();
			string thisLevel = Application.loadedLevelName;
			int intThisLevel = int.Parse(thisLevel);
			int intNextLevel = intThisLevel+1;
			string nextLevel = intNextLevel.ToString();
			Application.LoadLevel(nextLevel);
		}
	}

看了原作者的视频,他是打算做一个叫做Door的脚本,把上面的那部分功能转移到这个脚本里面去,然后再去把这个东西拉到我们的Door物体上,其实这一步可做可不做。我想来想去,这么做的好处大概就是可以为每一个Door弄一个public string Level,然后设置那些穿越什么的方便一些吧。

视频里面的Door脚本里面的GameManager脚本还是采用将它改为public,然后再在外面进行拖拽的方法。那么有没有什么方法可以不进行这些拖拽呢?当然可以的啊。只要利用GetComponent就可以了。下面贴出我写的脚本:

public class Door : MonoBehaviour {

    public string Level;
    GameManager gameManager;
    GameObject gameObject;

    void Start() {
        gameObject = GameObject.FindGameObjectWithTag("GameManager");
        gameManager = gameObject.GetComponent<GameManager>();
    }

    void OnTriggerEnter(Collider other)
    {
        if (other.tag == "Player")
        {
            gameManager.SaveGame();
            Application.LoadLevel(Level);
        }
    }
}

我们首先要定义一个GameObject来利用FindGameObjectWithTag得到它(使用这种方法需要增加tag,其实和拖拽方法大同小异,这个就看个人喜好了。两种方法大家都可以使用的)当然,我们要给GameManager加上一个同名的tag。

接着我们就利用这个:gameManager = gameObject.GetComponent<GameManager>();很简单,我就不解释了,不过需要注意一下格式。

如果在函数上有什么不太明白的,建议大家自己去查查手册。链接:http://game.ceeger.com/search/

另外呢,在实际操作过程中,如果大家按照我上面的脚本照抄一遍,就会出现这样的黄字警告:Assets/Scripts/Door.cs(8,16): warning CS0108: `Door.gameObject' hides inherited member `UnityEngine.Component.gameObject'. Use the new keyword if hiding was intended 这到底是怎么回事呢?一开始我真的想不明白,用百度和Google也找不到解答。后来自己想了想,然后又查了一下词典,总算明白到底是怎么一回事了。我们都知道,黄字警告一般是不会影响游戏的运行的,但是肯定是有某些不太合理的地方,所以我们的Unity会非常人性化地给我们提个醒。我经常碰到的黄字提醒一般都是定义了某个变量,但是从未使用过。而这次碰到的这个情况,是脚本里面的gameObject变量和系统里面本身自带的gameObject实例名称重复了。因为我们知道,gameObject是个关键词,所以如果我起了个这样的名字的话,虽然是可以运行的,但是Unity也许容易混淆,所以它就会给我们一个这样的提醒。这里也顺便提醒一下大家,在定义变量的时候尽量不要使用和系统自定义的关键词重复的东西。否则可能会出现比较严重的问题。

上面的这个问题,我们只需要把gameObject改成gameObjectGM,就不会报错了。

好了,那么这一讲想要实现的功能就都实现了。我们可以接着下一讲的内容了:

(顺便说一下,我不是很喜欢unity4.3版本里面自带的monodevelop脚本编辑器,所以我改成了VS2013,这篇写完之后我会写一篇替换编辑器的内容。)


这是我处理过的VS2013的界面,感觉还是挺和谐的嘻嘻。下一篇我们再聊这个。

由于排版方面出了很奇怪的问题,所以第二篇就只能先到这里结束了。大概是因为我弄了太多天的同一篇,又有些东西是复制进来的,导致排版的时候总是出错吧。先这样吧,下一篇我会将没有说完的补完。



            

没有更多推荐了,返回首页