与游戏世界交互
PS:所有代码请点击下方代码传送门
代码传送门
目录
编写一个简单的鼠标打飞碟(Hit UFO)游戏
- 游戏内容要求:
- 游戏有 n 个 round,每个 round 都包括10 次 trial;
- 每个 trial 的飞碟的色彩、大小、发射位置、速度、角度、同时出现的个数都可能不同。它们由该 round 的 ruler 控制;
- 每个 trial 的飞碟有随机性,总体难度随 round 上升;
- 鼠标点中得分,得分规则按色彩、大小、速度不同计算,规则可自由设定。
- 游戏的要求:
- 使用带缓存的工厂模式管理不同飞碟的生产与回收,该工厂必须是场景单实例的!具体实现见参考资源 Singleton 模板类
- 近可能使用前面 MVC 结构实现人机交互与游戏模型分离
先上个最终结果图吧:
接下来,咱们开始说下怎么做咯。
设置天空盒
首先吧,如果啥背景都不用,这个画面是真的太丑了,所以我们就先加个天空盒。这里我用的是 Fantasy Skybox Free,且直接使用了里面的 Demo 场景(懒呀,不想自己搞了
然后呢,就修改一下摄像机的位置之类的,调整到一个合适的位置。最终呢,摄像机看到的就这样了:
设计游戏规则
游戏规则如下:
- 游戏分三个回合,每个回合按概率发送不同飞碟,分别为蓝、绿、红三种颜色,size逐渐减小
- 第一回合每隔2s发送一个飞碟,第二回合1.5s,第三回合1s
- 点击鼠标左键射击,击中蓝、绿色飞碟得1分,击中红色得两分
- 总共发射出10个飞碟后进入第二回合,发出25个飞碟后进入第三回合
- 玩家共有六滴血,一个飞碟飞出视野仍未击中后扣一滴血
规则很简单,毕竟游戏也是个比较简单的游戏嘛
程序编写
Models
这次 Models 就非常简单,仅仅包括了 ISceneController, IUserAction 及 SSDirector。其实我是想把 记分员和飞碟工厂 作为 Models 写进去的,但是对于单实例,在获取实例时,如 Singleton<DiskFactory>.Instance 时,提示需要将脚本挂载在一个对象上,所以最终我就将这两个给分离出来作为单独的脚本了。这部分不怎么重要,就不放代码了。
Actions
虽然这次基本没什么动作,但还是分离下吧,具体咋分离,请看上一篇博客在做牧师与魔鬼过河中的介绍。
传送门
本次相比与上次肯定有所区别,主要在于以下方面:
- 这次的单一动作换成了飞碟飞行动作 CCUFOFlyAction
- 组合动作虽然也写在了代码里,但并没有使用
- 本次的具体动作管理者是 CCFlyActionManager
下面是这两个类实现的代码:
public class CCUFOFlyAction : SSAction{
public int side;
public float x_speed = 0.0f;
public float y_speed = 0.0f;
// public float time = 0.0f;
public float g = -3.0f;
private CCUFOFlyAction() { }
public static CCUFOFlyAction GetSSAction(int s, float start){
CCUFOFlyAction action = ScriptableObject.CreateInstance<CCUFOFlyAction>();//让unity创建动作类,确保内存正确回收
action.side = s;
if(action.side==-1) // -1: left side; 1: right side
action.x_speed = start;
else
action.x_speed = -start;
return action;
}
public override void Update(){
// time += Time.deltaTime;
this.transform.position += new Vector3(0,y_speed*Time.deltaTime,x_speed*Time.deltaTime);
y_speed += g*Time.deltaTime;
if (this.transform.position.y<90 || this.transform.position.z<-15 || this.transform.position.z>15){
// waiting for destroy
this.destroy = true;
this.callback.SSActionEvent(this); //告诉动作管理或动作组合这个动作已完成
}
}
public override void Start(){
// 不做任何事情
}
}
public class CCFlyActionManager : SSActionManager, ISSActionCallback{
public CCUFOFlyAction fly;
public FirstController sceneController;
protected new void Start(){
sceneController = (FirstController)SSDirector.GetInstance().CurrentScenceController;
sceneController.action_manager = this;
}
public void UFOFly(GameObject UFO, int side, float start_speed){
fly = CCUFOFlyAction.GetSSAction(side,start_speed);
this.RunAction(UFO, fly, this);
}
#region ISSActionCallback implementation
public void SSActionEvent(SSAction source, SSActionEventType events = SSActionEventType.Competeted,
int intParam = 0, string strParam = null, Object objectParam = null){
// 不做任何事情
return;
}
#endregion
}
CCUFOFlyAction:
- side:初始位置在左边还是右边
- x_speed/y_speed:x与y方向上的速度
- g:重力加速度的值
- 实现逻辑:设置初始位置,并根据重力加速度计算出某个时刻的位置
CCFlyActionManager:
- 通过传入的飞碟对象、对象初始所在边以及初速度来生成传入的对象的动作
Singleton
该类的作用很简单,就是为了实现单实例。代码如下:
public class Singleton<T> : MonoBehaviour where T : MonoBehaviour{
protected static T instance;
public static T Instance {
get {
if (instance == null) {
instance = (T)FindObjectOfType (typeof(T));
if (instance == null) {
Debug.LogError ("An instance of " + typeof(T) +
" is needed in the scene, but there is none.");
}
}
return instance;
}
}
}
那么,他是如何实现单实例的呢?
- 将需要是单实例的类去继承类 MonoBehaviour
- 之后就可以获取该类的单实例了,例如:disk_factory = Singleton.Instance
- 注意:这里要将需要单实例的类的脚本挂载在一个游戏对象上才能如上获取单实例,因此我对于所有的需要单实例的类都用了一个单独的脚本实现
DiskData
DiskData也是本次相比于上次作业新加入的内容,该脚本是作为对象的属性使用的。怎么理解呢?就比如你可以利用它来设置对象本身的大小、颜色等,也可以利用它来实现游戏逻辑,比如用它的分值来实现得分。将其作为属性脚本挂载在对象上后,就没必要在实现中用很多的 list 去保存一个实例对象所具有的各种属性了。
[System.Serializable]
public class DiskData : MonoBehaviour{
[Tooltip("分值")]
public int score = 1;
[Tooltip("大小")]
public Vector3 size = new Vector3( 1 ,1, 1.2f);
[Tooltip("颜色")]
public Color color = Color.blue;
public int appear_side; // -1: left; 1: right
}
DiskFactory
该类的作用很简单,就是飞碟的提供与回收。主要有两个函数:GetDisk()、FreeDisk()。GetDisk() 使得其他类能获取到飞碟,FreeDisk() 则用于回收飞碟。
另外,在实现工厂的时候,还用到了两个本次作业很重要的概念:单实例与对象池。单实例前面已经大致介绍过了,就是全局只会有一个实例化对象。对象池的话也很简单啦,就是在回收的时候并没有让系统实际地销毁掉对象,而是保存在了一个链表里面。为什么要这么搞呢?因为对象的创建和回收很耗时耗力。因此,在创建之后,就会一直保存在某个队列当中:used或free。获取飞碟就是从 free 队列中移除一个飞碟,加入到 used 队列中;回收工作就只是将 used 队列中的飞碟移除,然后加入到 free 队列中。
public class DiskFactory : MonoBehaviour{
public GameObject disk_prefab = null; // 用于保存GetDisk结果并返回
private List<DiskData> used = new List<DiskData>(); // 正在使用的飞碟属性
private List<GameObject> used_object = new List<GameObject>(); // 正在使用的飞碟对象
private List<DiskData> free = new List<DiskData>(); // 空闲飞碟属性
private List<GameObject> free_object = new List<GameObject>(); // 空闲飞碟对象
public GameObject GetDisk(int round){
int choice = 0;
int scope1 = 1, scope2 = 4, scope3 = 7; // 控制概率
float start_y = 0.0f;
string tag;
disk_prefab = null;
//根据回合,随机选择要飞出的飞碟
if (round == 1)
choice = Random.Range(0, scope2);
else if(round == 2)
choice = Random.Range(0, scope3);
else
choice = Random.Range(scope1, scope3);
if(choice <= scope1)
tag = "UFO1";
else if(choice <= scope2 && choice > scope1)
tag = "UFO2";
else
tag = "UFO3";
//寻找相同标签的空闲飞碟
for(int i=0;i<free.Count;i++){
if(free_object[i].tag == tag){
disk_prefab = free_object[i];
free.Remove(free[i]);
free_object.Remove(free_object[i]);
break;
}
}
// 如果空闲列表中没有,则实例出一个新的飞碟
if(disk_prefab == null){
if (tag == "UFO1")
disk_prefab = Instantiate(Resources.Load<GameObject>("Prefabs/UFO1"), new Vector3(0, start_y, 0), Quaternion.Euler(90,0,0));
else if (tag == "UFO2")
disk_prefab = Instantiate(Resources.Load<GameObject>("Prefabs/UFO2"), new Vector3(0, start_y, 0), Quaternion.Euler(90,0,0));
else
disk_prefab = Instantiate(Resources.Load<GameObject>("Prefabs/UFO3"), new Vector3(0, start_y, 0), Quaternion.Euler(90,0,0));
// 给新飞碟设置Material及DiskData
int ran_z = Random.Range(-1f, 1f) < 0 ? -1 : 1;
disk_prefab.GetComponent<Renderer>().material.color = disk_prefab.GetComponent<DiskData>().color;
disk_prefab.GetComponent<DiskData>().appear_side = ran_z;
disk_prefab.transform.localScale = disk_prefab.GetComponent<DiskData>().size;
}
// 将新飞碟添加到使用列表中
used.Add(disk_prefab.GetComponent<DiskData>());
used_object.Add(disk_prefab);
return disk_prefab;
}
//回收飞碟
public void FreeDisk(GameObject disk){
for(int i = 0;i < used.Count; i++){
if (disk.GetInstanceID() == used_object[i].GetInstanceID()){
used_object[i].SetActive(false);
free.Add(used[i]);
free_object.Add(used_object[i]);
used.Remove(used[i]);
used_object.Remove(used_object[i]);
break;
}
}
}
public void Restart(){
for(int i = 0;i < used.Count; i++){
FreeDisk(used_object[i]);
}
}
}
ScoreRecorder
记分员主要就一个工作:计分,实现也很轻松:
public class ScoreRecorder : MonoBehaviour{
public int score;
void Start (){
score = 0;
}
public void Record(GameObject disk){
int get = disk.GetComponent<DiskData>().score;
score += get;
}
public void Reset(){
score = 0;
}
}
FirstController
这次场景控制器需要做的事情就很多了,但从变量上就能看出来喽:
public CCFlyActionManager action_manager;
public DiskFactory disk_factory;
public ScoreRecorder score_recorder;
public UserGUI user_gui;
private Queue<GameObject> disk_queue = new Queue<GameObject>(); // 从工厂获取但还未被发送的飞碟
private List<GameObject> disk_send = new List<GameObject>(); // 发送但尚未被击中飞碟
private int round = 1; // 回合数
private float time_interval = 2f; // 发射一个飞碟的时间间隔
private int game_state = 0; // 0: initial; 1: start game; 2: gameover
private int send_count = 0; // 已发送飞碟数
private int round2_send = 10; // 进入第二回合发送飞碟数
private int round3_send = 25; // 进入第二回合发送飞碟数
private float time = 0.0f; // 记录距离上一次发送飞碟过去的时间
具体而言,需要做如下这些事情:
- 初始化
- 实现游戏逻辑,如定时发送飞碟、判断进入下一回合、游戏结束等
- 返回回合数
- 加载飞碟
- 实现击中飞碟: Hit()。这里用上了新学的东西:Ray(射线)
- 返回当前分值
- 发送飞碟:SendDisk()
虽然内容有点多,但实际实现并不复杂,想清楚逻辑即可。代码就不放了,一百多行呢,太臭太长了。
UserGUI
UserGUI的话就没啥营养了,除了响应鼠标点击事件以外,就是设计 按钮 和 文本 内容及位置了。当然,这也是个苦力活呀。。。即使设计的没那么好看,都需要搞好久,是真的烦
实验结果
说了这么多,上个游戏过程瞅瞅咯。比较丑,毕竟没弄些好看的模型和爆炸效果啥的,将就下呗。
游戏开始:
游戏中:
游戏结束:
游戏动画: