作为一个将来想从事游戏行业的人来说,从一些经典游戏来学习Unity是最好不过的了,在这里我就介绍一下这次做的贪吃蛇。
贪吃蛇制作教学
详细的可以从上面链接进去学习,素材也从上方链接中取得。如果只是想了解整体框架和一些技术问题可以继续往下看。
我这篇博客更像是学习笔记,所以各位读者在一些细节的,不懂的地方,可以在评论区留言给我,我会尽我所能帮大家解决。
首先是场景的搭建:
我们需要两个场景,一个是开始界面场景,一个是游戏场景。
开始界面
开始界面内容清单:
包含模式选择和皮肤选择(详细的处理在后文)
游戏场景:
游戏场景内容清单:
这里的Up,Down,Right和Left是四条边界
两个场景的ScriptsHolder是脚本控制器,分别用于控制不同的场景。
那么游戏场景就搭建好了,接下来是组件的一些注意事项:
1.开始界面的Skin和Model选择,需要让它们的子项属于同一个ToggleGroup中,这样就可以保证只有一个被选中。
2.记得给能碰撞到的物体都添加碰撞检测(BoxCollider)组件,而蛇头需要添加Ridbody2D和BoxCollider组件。
3.在File->Building Setting中添加这两个场景
4.预制体!
这四个预制体没什么好说的,一个是食物,一个是奖励物,一个是蛇头,一个是蛇身,需要注意的是都要添加碰撞检测组件。
接下来是最关键的脚本环节(所有脚本我都添加好注释了,直接挂载然后拖动元素赋值即可)
在开始场景的Scripts挂载StartUIController脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class StartUIController : MonoBehaviour
{
public Text lastText;
public Text bestText;
public Toggle white;
public Toggle colors;
public Toggle border;
public Toggle noBorder;
void Awake() {
lastText.text = "上次:长度" + PlayerPrefs.GetInt("lastl",0) + "分数" + PlayerPrefs.GetInt("lasts",0);
bestText.text = "最好:长度" + PlayerPrefs.GetInt("bestl",0) + "分数" + PlayerPrefs.GetInt("bests",0);
}
void Start()
{
if(PlayerPrefs.GetString("sh","sh01")=="sh01")//这个是让数据随场景跳转传递的代码
{
white.isOn=true;
PlayerPrefs.SetString("sh","sh01");
PlayerPrefs.SetString("sb01","sb0101");
PlayerPrefs.SetString("sb02","sb0102");
}
else
{
colors.isOn=true;
PlayerPrefs.SetString("sh","sh02");
PlayerPrefs.SetString("sb01","sb0201");
PlayerPrefs.SetString("sb02","sb0202");
}
if(PlayerPrefs.GetInt("border",1)==1)
{
Debug.Log("边界模式启动");
border.isOn = true;
PlayerPrefs.SetInt("border",1);
}
else
{
Debug.Log("无边界模式启动");
noBorder.isOn = true;
PlayerPrefs.SetInt("border",0);
}
}
//以下代码为模式和皮肤选择
public void WhiteSelected(bool isOn)
{
if(isOn)
{
PlayerPrefs.SetString("sh","sh01");//存储皮肤文件的文件名,我这里都用SnakeHead
PlayerPrefs.SetString("sb01","sb0101");
PlayerPrefs.SetString("sb02","sb0102");
}
}
public void ColorsSelected(bool isOn)
{
if(isOn)
{
PlayerPrefs.SetString("sh","sh02");
PlayerPrefs.SetString("sh01","sb0201");
PlayerPrefs.SetString("sh02","sb0202");
}
}
public void BorderSelected(bool isOn)
{
if(isOn)
{
PlayerPrefs.SetInt("border",1);
}
}
public void NoBorderSelected(bool isOn)
{
if(isOn)
{
PlayerPrefs.SetInt("border",0);
}
}
public void StartGame()//开始游戏按钮
{
UnityEngine.SceneManagement.SceneManager.LoadScene(1);
}
}
然后是游戏场景的脚本MainUiController,挂载到游戏场景的ScrptsHolder上
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class MainUIController : MonoBehaviour
{
private static MainUIController _instance;//单例模式访问
public static MainUIController Insatance
{
get
{
return _instance;
}
}
public bool hasBorder = true;//边界标识位
public bool isPause = false;
public int score = 0;//存放分数
public int length = 0;//存放长度
public Text scoreText;
public Text msgText;
public Text lengthText;
public Image bgImage;
public Image pauseImage;
public Sprite[] pauseSprites;
private Color tempColor;
void Awake()
{
_instance = this;
}
void Start() {
if(PlayerPrefs.GetInt("border",1)==0)//边界模式切换操作
{
hasBorder = false;
foreach(Transform t in bgImage.gameObject.transform)//遍历bgImage下的所有子物体
{
t.gameObject.GetComponent<Image>().enabled = false;
}
}
}
void Update()
{
switch(score)
{
case 10:
ColorUtility.TryParseHtmlString("#CCEEFFFF",out tempColor);//解析16进制颜色组
bgImage.color = tempColor;
msgText.text = "阶段"+2;
break;
case 20:
ColorUtility.TryParseHtmlString("#CCFFDBFF",out tempColor);//解析16进制颜色组
bgImage.color = tempColor;
msgText.text = "阶段"+3;
break;
case 30:
ColorUtility.TryParseHtmlString("#EBFFCCFF",out tempColor);//解析16进制颜色组
bgImage.color = tempColor;
msgText.text = "阶段"+4;
break;
case 40:
ColorUtility.TryParseHtmlString("#FFF3CCFF",out tempColor);//解析16进制颜色组
bgImage.color = tempColor;
msgText.text = "无尽阶段";
break;
}
}
public void updateUI(int s=1,int l=1)//加多少分数和多少长度
{
score +=s;
length +=l;
scoreText.text = "得分:\n" + score;
lengthText.text = "长度:\n"+ length;
}
public void Pause()
{
isPause = !isPause;
if(isPause)
{
Time.timeScale = 0;//时间冻结
pauseImage.sprite = pauseSprites[1];
}
else
{
Time.timeScale = 1;//时间继续
pauseImage.sprite = pauseSprites[0];
}
}
public void Home()
{
UnityEngine.SceneManagement.SceneManager.LoadScene(0);
}
}
同时也需要它控制食物的生成。
所以脚本FoodMaker也是挂载在这个结点上
以下是FoodMaker代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class FoodMaker : MonoBehaviour
{
public int xlimit = 21;//从0,0到最右边限制21步(步数为测量后得出)
public int ylimit = 11;//到上边的限制
public int xoffset = 7;//向左为14 21-7=14
public GameObject foodPrefab;//食物预设体
public GameObject rewardPrefab;//随机奖励预制体
public Sprite[] foodSprites;//食物样式的数组
public static FoodMaker Instance//使用这个进行访问
{
get
{
return _instance;
}
}
private Transform foodHolder;
private static FoodMaker _instance;//使用单例模式被访问,用于食物被吃掉后生成
void Awake()
{
_instance = this;//在脚本刚被调用时赋值为this
}
void Start()
{
foodHolder = GameObject.Find("FoodHolder").transform;//获得到名字为FoodHolder的物体
MakeFood(false);
}
public void MakeFood(bool isReward)//需要被蛇头碰撞调用生成新食物,参数为本次生成是否需要生成奖励物
{
int index = Random.Range(0,foodSprites.Length);//随机获取食物样式
GameObject food = Instantiate(foodPrefab);//用Instantiate函数实例化预制体
food.GetComponent<Image>().sprite = foodSprites[index];//给食物样式,Image数组需要引入UI组件
food.transform.SetParent(foodHolder,false);//设置参数为false让其保持坐标不变
int x = Random.Range(-xlimit+xoffset,xlimit);//设置随机刷新位置X
int y = Random.Range(-ylimit,ylimit);//设置随机刷新位置y
food.transform.localPosition = new Vector3(x*30,y*30,0);//这里的30代表我们每分割一个步长是30
if(isReward)
{
GameObject reward = Instantiate(rewardPrefab);//用Instantiate函数实例化预制体
reward.transform.SetParent(foodHolder,false);//设置参数为false让其保持坐标不变
x = Random.Range(-xlimit+xoffset,xlimit);//设置随机刷新位置X
y = Random.Range(-ylimit,ylimit);//设置随机刷新位置y
reward.transform.localPosition = new Vector3(x*30,y*30,0);//这里的30代表我们每分割一个步长是30
}
}
}
然后是这个结点需要拖拽设置的对象:
然后蛇头挂载的控制脚本SnakeHead
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using System.Linq;
public class SnakeHead : MonoBehaviour
{
public List<Transform> bodyList = new List<Transform>();//使用LIST存储蛇身
// 属性的Public和Private将决定在面板上能不能直接修改
public float velocity=0.35f;//相当于帧率
public int step;//蛇每一步走多少
private int x;//X,Y代表增量值
private int y;
private Vector3 headPos;
private Transform canvas;
private static FoodMaker _instance;
private bool isDie = false;
public AudioClip eatClip;
public AudioClip dieClip;
public GameObject dieEffect;
public GameObject bodyPrefab;
public Sprite[] bodySprites = new Sprite[2];//用于控制蛇身样式
void Awake()
{
canvas = GameObject.Find("Canvas").transform;
//路径不需要写扩展名和Resources/
//可以使用泛型Resources.Load
gameObject.GetComponent<Image>().sprite = Resources.Load<Sprite>(PlayerPrefs.GetString("sh","sh02"));//加载Resources文件夹里的资源
bodySprites[0] = Resources.Load<Sprite>(PlayerPrefs.GetString("sb01","sb0201"));
bodySprites[1] = Resources.Load<Sprite>(PlayerPrefs.GetString("sb02","sb0202"));
}
void Start()
{
InvokeRepeating("Move",0,velocity);//InvokeRepeating是重复调用,
//第一个参数是在本脚本里调用方法的方法名,字符串传递
//第二个参数是多久开始调用
//第三个参数是每隔多少时间调用一次
x = 0;y=step;//让贪吃蛇一开始向右走,不然会静止不动
}
void Update()
{
if(isDie)
{
return;//如果死亡就直接返回,但还不知道这样做有什么问题
}
//空格加速控制模块
if(Input.GetKeyDown(KeyCode.Space) && MainUIController.Insatance.isPause==false&&isDie==false)//GetKeyDown()函数是获取按下的按键
{
CancelInvoke();//使得当前重复调用的函数暂停
InvokeRepeating("Move",0,velocity-0.3f);//这里的加速方法是加快函数的调用达到刷新效果
}
if(Input.GetKeyUp(KeyCode.Space)&&MainUIController.Insatance.isPause==false&&isDie==false)
{
CancelInvoke();
InvokeRepeating("Move",0,velocity);
}
//方向控制模块
if(Input.GetKey(KeyCode.W)&&y != -step&&MainUIController.Insatance.isPause==false&&isDie==false)//y!=-step控制蛇不能直接回头
{
//调整图片使其跟随方向旋转↓
gameObject.transform.localRotation = Quaternion.Euler(0,0,0);//需要用四元数Quaternion赋值
//给位移量赋值↓
x=0;y=step;
}
if(Input.GetKey(KeyCode.S)&&y != step&&MainUIController.Insatance.isPause==false&&isDie==false)
{
gameObject.transform.localRotation = Quaternion.Euler(0,0,180);
x=0;y=-step;
}
if(Input.GetKey(KeyCode.A)&&x != step&&MainUIController.Insatance.isPause==false&&isDie==false)
{
gameObject.transform.localRotation = Quaternion.Euler(0,0,90);
x=-step;y=0;
}
if(Input.GetKey(KeyCode.D)&&x != -step&&MainUIController.Insatance.isPause==false&&isDie==false)
{
gameObject.transform.localRotation = Quaternion.Euler(0,0,-90);
x=step;y=0;
}
}
void Move()
{
headPos = gameObject.transform.localPosition;//保存下来蛇头移动前的位置
gameObject.transform.localPosition = new Vector3(headPos.x+x,headPos.y+y,headPos.z);//使用Vector3进行赋值,蛇头移动
if(bodyList.Count>0)//要至少有一个蛇身才进入,不然Last会返回空指针
{
//第一种方法,将尾巴移动到头的位置
//该方法有两个问题,第一是每一次吃都会在原点重新生成,会闪烁一次(可以将蛇身生成在屏幕外解决此问题,或者将Grow函数放进Move函数中)
// 第二是如果蛇身颜色不一样,会看得出来排序出错
//↓代码
// bodyList.Last().localPosition = headPos;//Last方法需要引入System.Linq
// bodyList.Insert(0,bodyList.Last());
// bodyList.RemoveAt(bodyList.Count-1);
//第二种方法,记载蛇身位置,整体移动(从后往前挪动,节省空间开销)
for(int i=bodyList.Count-2;i>=0;i--)
{
bodyList[i+1].localPosition = bodyList[i].localPosition;
}
bodyList[0].localPosition = headPos;//第一节身体等于头的位置
}
}
void Grow()//蛇身生长函数
{
AudioSource.PlayClipAtPoint(eatClip,Vector3.zero);//零点播放音效
int index =(bodyList.Count%2==0) ? 0 : 1;//==0取0否则取1
GameObject body = Instantiate(bodyPrefab,new Vector3(2000,2000,0),Quaternion.identity);//将生成的蛇身放在看不到的位置
body.GetComponent<Image>().sprite = bodySprites[index];
body.transform.SetParent(canvas,false);//将蛇身加入画布
bodyList.Add(body.transform);
}
void Die()//蛇的死亡方法
{
AudioSource.PlayClipAtPoint(dieClip,Vector3.zero);//零点播放音效
CancelInvoke();//取消移动
isDie = true;
Instantiate(dieEffect);//调用死亡特效
PlayerPrefs.SetInt("lastl",MainUIController.Insatance.length);//记录了最后的长度
PlayerPrefs.SetInt("lasts",MainUIController.Insatance.score);
if(PlayerPrefs.GetInt("bests",0)<MainUIController.Insatance.score)//最佳成绩判断
{
PlayerPrefs.SetInt("bestl",MainUIController.Insatance.length);
PlayerPrefs.SetInt("bests",MainUIController.Insatance.score);
}
StartCoroutine(GameOver(1.5f));//调用协同程序Coroutine
}
// coroutine本身提供了一种机制,让开发者可以控制一个代码片段↓
// 让它暂停,然后在下次调用时继续从上次暂停的地方继续运行。
IEnumerator GameOver(float t)//协同程序Coroutine
{
yield return new WaitForSeconds(t);//告诉游戏系统等待一段时间再执行下方代码
UnityEngine.SceneManagement.SceneManager.LoadScene(1);//重载当前场景
}
private void OnTriggerEnter2D(Collider2D collision)//触发器方法,和碰撞器有点区别
{
//也可以写成collision.tag=="Food"
if(collision.gameObject.CompareTag("Food"))//检测Tag是否等于Food
{
Destroy(collision.gameObject);//销毁碰撞器所在的游戏物体
MainUIController.Insatance.updateUI();
Grow();
FoodMaker.Instance.MakeFood((Random.Range(0,100)<20)? true : false);//%20概率生成奖励物
}
else if(collision.gameObject.CompareTag("Reward"))
{
Destroy(collision.gameObject);
MainUIController.Insatance.updateUI(Random.Range(1,5));
Grow();
}
else if(collision.gameObject.CompareTag("Body"))
{
//要注意碰撞器大小或者是蛇头和蛇身的间隔,不然每一次调用Grow都会自毁
Debug.Log("撞到身体了");
Die();
}
else
{
if(MainUIController.Insatance.hasBorder)//边界模式下撞击死亡
{
Die();
}
else//无边界模式
{
switch(collision.gameObject.name)
{
case "Up"://自由模式撞到上边界传送到下边界
transform.localPosition = new Vector3(transform.localPosition.x,-transform.localPosition.y+step,transform.localPosition.z);
break;//偏移值step是为了传送后不触发下边界的碰撞,让它多走一步
case "Down"://自由模式撞到下边界传送到上边界
transform.localPosition = new Vector3(transform.localPosition.x,-transform.localPosition.y-step,transform.localPosition.z);
break;
case "Left":
transform.localPosition = new Vector3(-transform.localPosition.x+(step*5),transform.localPosition.y,transform.localPosition.z);
break;
case "Right":
transform.localPosition = new Vector3(-transform.localPosition.x+(step*8),transform.localPosition.y,transform.localPosition.z);
break;
}
}
}
}
}
然后基本上就搭建完毕了,因为贪吃蛇大体上来说还是很简单的。脚本的大部分地方我都给予了注释,帮助大家理解。
接下来是我在这次实践中印象深刻的一些细节:
1.场景带数据的跳转,用的是PlayerPrefs的存储+Resouce文件夹的扫描加载
2.死亡后的场景延迟加载,用的是协同程序Coroutine
3.加速小蛇移动用的是增加单位时间内函数调用的次数(虽然个人觉得这样好像有点问题,但是还不知道具体的问题在哪)
4.由于项目简单,脚本之间的访问调用用的都是单例模式
这个游戏大体上还是很简单的。所以没有特别多让我很困惑的地方。
此外,以后我学习做游戏还是同步更新好了,这样一整个游戏的写总结还是太粗糙了。