Unity实现打飞碟小游戏
前言
这是中大计算机学院3D游戏编程课的一次作业,在这里分享一下设计思路。
主要代码上传到了gitee上,请按照后文的操作运行。
项目地址:https://gitee.com/cuizx19308024/unity-games/tree/master/hw4
成果视频:https://www.bilibili.com/video/BV1fb4y1h7Ex?spm_id_from=333.999.0.0
游戏说明
游戏内容要求
- 游戏有 n 个 round,每个 round 都包括10 次 trial。
- 每个 trial 的飞碟的色彩、大小、发射位置、速度、角度、同时出现的个数都可能不同。它们由该 round 的 ruler 控制。
- 每个 trial 的飞碟有随机性,总体难度随 round 上升。
- 鼠标点中得分,得分规则按色彩、大小、速度不同计算,规则可*设定。
游戏设计要求
- 使用带缓存的工厂模式管理不同飞碟的生产与回收,该工厂必须是场景单实例的!具体实现见参考资源 Singleton 模板类。
- 尽可能使用前面 MVC 结构实现人机交互与游戏模型分离。
- 按 adapter模式设计图修改飞碟游戏使它同时支持物理运动与运动学(变换)运动。
项目组成和运行环境
-
这里的Assets提前导入了Fantax SkyBox的资源包,用作背景。
-
如果加载不出场景,则点击Scene中的SampleScene来加载场景。
-
Resources中包含了材料和飞碟预制。
-
Scripts中包含如下内容:
-
注意,这里可能需要手动将FirstController拖入到MainCamera上。
设计思路
整体思路
根据项目组成,可以看出:
- 模板:Singleton单例模板。
- 导演、控制器相关:SSDirector单例模式、ISceneController场景接口、FirstController游戏控制器。
- 用户相关:IUserAction用户动作接口、UserGUI用户界面。
- Disk相关:DiskData数据类型、DiskFactory工厂模式(定义飞碟属性)。
- 动作相关:SSAction动作基类、ISSActionCallBack动作结束的回调接口、CCFlyAction运动学动作、PhysicalAction动力学动作。
- 动作管理相关:SSActionManager动作管理基类、IActionManager飞行动作管理的接口(Adapter模式)、CCActionManager运动学动作管理、PhysicalActionManager动力学动作管理。
- 辅助类:ScoreRecorder记分员。
由于不方便在一张UML图中展示,因此在这里借用老师的图辅助理解。
预制、天空盒背景
首先应当完成UFO的预制,这里直接搭建两个球。修改scale、修改碰撞体积。此外还需要加一个RigidBody的属性,并设置需要重力,以便完成后续的运动学模式操作。做成预制,放在Resources文件夹下。
单例模板
这里直接借用了老师的模板:
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;
}
}
}
导演、接口
重用之前作业的代码即可:
public class SSDirector : System.Object
{
private static SSDirector _instance;
public ISceneController CurrentScenceController { get; set; }
public static SSDirector GetInstance()
{
if (_instance == null)
{
_instance = new SSDirector();
}
return _instance;
}
}
public interface ISceneController
{
void LoadResources();
}
FirstController控制器
这是这个游戏的唯一主控制器,管理轮次记录、发送飞碟、处理点击事件等。
由于篇幅过长,这里只说明几个关键点:
- 添加脚本和初始化对象不是同一操作,需要注意分开进行。
void Start() { //首先添加导演 SSDirector.GetInstance().CurrentScenceController = this; //然后添加脚本,并在此初始化对象 gameObject.AddComponent<DiskFactory>(); gameObject.AddComponent<CCActionManager>(); gameObject.AddComponent<PhysicalActionManager>(); userGUI = gameObject.AddComponent<UserGUI>(); //需要单例模式的,应当在此初始化对象 diskFactory = Singleton<DiskFactory>.Instance; actionManager = Singleton<CCActionManager>.Instance;//默认是运动学模式 //加载其他资源 LoadResources(); }
- 发射飞碟要从工厂类中获取,且需要随机生成发射位置:
public void SendDisk(){ GameObject disk = diskFactory.GetDisk(round); float side = -disk.GetComponent<DiskData>().direction.x; //与速度水平方向相反 disk.transform.position = new Vector3(side * 15f, UnityEngine.Random.Range(0f, 8f), 0); disk.SetActive(true); //激活 actionManager.Fly(disk, disk.GetComponent<DiskData>().speed, disk.GetComponent<DiskData>().direction);//设置飞行动作 }
- 击中处理必须注意以下细节:
最初由于没有考虑点击时Free对象对FlyAction的影响,导致已经被Free的对象却仍然有FlyAction绑定。这样就导致从free的对象池中重新生成的Disk对象,即使本身的速度参数和位置参数是正确的,但是实际的运动轨迹不符合预期(原来的FlyAction和这个FlyAction叠加了)。
未点击且正常完成运动(运动出规定区域)的飞碟会调用回调函数,之后自动回收,即Free即代表FlyAction动作结束,因此不存在此问题。但是Hit只是Free掉了,FlyAction动作并未结束,这样就会出现上述问题。因此可以考虑在Hit的时候也让FlyAction结束,方法是让对象的位置出界。
此外还需要注意的是,C#和JAVA类似,基本数据类型是按值传递的,而对象才是按引用传递的。因此修改position时,只是进行:disk.transform.position.y = -20;
操作无效(编译器就会给出警告),因为这是按值传递。因此,必须按代码中的方法进行修改。public void Hit(Vector3 position){ Camera camera = Camera.main; Ray ray = camera.ScreenPointToRay(position); RaycastHit[] hits = Physics.RaycastAll(ray); for(int i=0;i<hits.Length;i++){ RaycastHit hit = hits[i]; if (hit.collider.gameObject.GetComponent<DiskData>() != null){//击中的对象不可以是被销毁的 GameObject disk = hit.collider.gameObject; disk.transform.position = new Vector3(0, -20, 0); //由于某些对象可能还被FlyAction绑定,因此需要先让FlyAction结束并回调 //此外,还要注意这里的position赋值方式(C#的按引用传递/按值传递) diskFactory.FreeDisk(disk); scoreRecorder.Record(disk); //计分 userGUI.score = scoreRecorder.score; } } }
- 清除屏幕上所有飞碟,因为如果中途突然Restart了,屏幕中还有剩余未点击的飞碟,因此需要让它们从屏幕上消失。之后确保FlyAction动作完全结束后(即不再有关联绑定),就可以Destroy对象了。
彻底销毁对象的主要目的是让上一局游戏中的剩余飞碟不要影响下一句游戏(尤其切换模式之后,否则会导致飞碟属性混乱)。public void ClearDisk(){ GameObject[] obj = FindObjectsOfType(typeof(GameObject)) as GameObject[]; //关键代码,获取所有gameobject元素给数组obj foreach (GameObject child in obj) { if (child.gameObject.name == "Disk") { child.gameObject.SetActive(false);//立刻灭活,不可点击 } } diskFactory.Clear();//让工厂彻底销毁 }
- 切换模式:
public void SetMode(int mode){ if(mode == 0){ actionManager = Singleton<CCActionManager>.Instance; } else if(mode == 1){ actionManager = Singleton<PhysicalActionManager>.Instance; } }
- 实时发送飞碟,Update逐帧更新运行,因此需要一个
cur_time
变量来计时:void Update() { //必须在开始阶段下才能交互 if(userGUI.status == 1){ //游戏未结束 if(round <= 10){ cur_time += Time.deltaTime; //每2秒做一次生成 if(cur_time > 2){ cur_time = 0; //游戏初始处理 if(round == 0){ round++; userGUI.round = round; } //每次随机发射1-4个飞碟,直到发射完为止 int rand_disk = Random.Range(1,5); for(int i=0; i<rand_disk; i++){ SendDisk(); cur_disk++; if(cur_disk == 10){ break; } } //发射够10个了,需要更新轮次 if(cur_disk == 10 && round <= 10){ cur_disk = 0; round++; userGUI.round = round; } } } //游戏已结束 if(round == 11){ userGUI.round = 10; cur_time += Time.deltaTime; //发射完飞碟后,并未完全点完,可以再给5s点击的时间,再让游戏进入结束状态。 if(cur_time > 5){ userGUI.status = 2; } } } }
用户动作接口和用户界面
用户动作接口如下:
public interface IUserAction
{
void Hit(Vector3 position);
void Restart();
void SetMode(int mode);
}
用户界面(注意几种游戏状态之间的转换,以及函数的回调):
void OnGUI()
{
GUI.Label(new Rect(300, 30, 50, 50), "打飞碟", bigStyle);
//准备阶段
if(status == 0){
if (GUI.Button(new Rect(320, 330, 100, 40), "开始游戏"))
{
status = 1;
userAction.Restart();
}
GUI.Box(new Rect(250, 230, 250, 80), "模式:");
mode = GUI.Toolbar (new Rect (275, 260, 200, 30), mode , toolbarModeStrings);
userAction.SetMode(mode);
}
//开始阶段
if(status == 1){
GUI.Label(new Rect(20, 20, 100, 50), "分数: " + score, style);
GUI.Label(new Rect(20, 80, 100, 50), "轮次: " + round, style);
if (Input.GetButtonDown("Fire1"))
{
userAction.Hit(Input.mousePosition);
}
if (GUI.Button(new Rect(20, 160, 100, 40), "重新开始"))
{
status = 0;
userAction.Restart();
}
}
//结束阶段
if(status == 2){
GUI.Label(new Rect(320, 100, 50, 40), "游戏结束", style);
GUI.Label(new Rect(320, 200, 50, 40), "总分: " + score, style);
if (GUI.Button(new Rect(330, 330, 100, 40), "重新开始"))
{
status = 0;
userAction.Restart();
}
}
}
Disk相关
数据类型,作为一个组件可以附加在GameObject对象上。
public class DiskData : MonoBehaviour
{
public float speed; //水平速度
public int points; //得分(红1,绿2,蓝3)
public Vector3 direction; //初始方向
}
DiskFactory工厂,这里只说明一些关键点:
- 工厂需要一个规则来创造飞碟对象。首先要检查对象池中是否有空对象,如果有则直接调用,否则再右系统生成。创造规则为:速度随round增加而增加,速度越快的飞碟体积越小、得分越高。
public GameObject GetDisk(int round){ GameObject disk; //如果有空闲的飞碟,则直接使用。否则生成新的飞碟 if (free.Count > 0) { disk = free[0].gameObject; free.Remove(free[0]); } else { disk = GameObject.Instantiate<GameObject>(disk_prefab, Vector3.zero, Quaternion.identity); disk.AddComponent<DiskData>(); } disk.gameObject.name = "Disk"; //生成飞碟的规则 //注意:位置的生成不由这个类管理 float base_speed = 3 + round * 0.8f; //根据round决定速度基数的大小 Vector3 base_scale = new Vector3(8,1,8); //颜色和大小有关 float rand_y = Random.Range(-0.5f, 0.5f); //速度垂直方向随机生成 int side = Random.Range(0,2); //速度水平方向(左或右) if(side == 0){ side = -1; } int rand_color = Random.Range(1,4); //颜色和速度有关 if(rand_color == 1){ disk.GetComponent<DiskData>().points = 1; disk.GetComponent<DiskData>().speed = 0.6f * base_speed; disk.GetComponent<DiskData>().direction = new Vector3(side, rand_y, 0); disk.GetComponent<Renderer>().material.color = Color.red; disk.GetComponent<Transform>().localScale = 1.2f * base_scale; } else if(rand_color == 2){ disk.GetComponent<DiskData>().points = 2; disk.GetComponent<DiskData>().speed = 1.2f * base_speed; disk.GetComponent<DiskData>().direction = new Vector3(side, rand_y, 0); disk.GetComponent<Renderer>().material.color = Color.green; disk.GetComponent<Transform>().localScale = base_scale; } else{ disk.GetComponent<DiskData>().points = 3; disk.GetComponent<DiskData>().speed = 1.8f * base_speed; disk.GetComponent<DiskData>().direction = new Vector3(side, rand_y, 0); disk.GetComponent<Renderer>().material.color = Color.blue; disk.GetComponent<Transform>().localScale = 0.8f * base_scale; } //将这一对象的DiskData属性添加到列表中 used.Add(disk.GetComponent<DiskData>()); return disk; }
- 释放时直接放回对象池中:
public void FreeDisk(GameObject disk){ foreach(DiskData d in used){ if(d.gameObject.GetInstanceID() == disk.GetInstanceID()){ disk.SetActive(false); //一定要先灭活 used.Remove(d); free.Add(d); //重新放回到对象池中 break; } } }
- 彻底销毁对象之前应当先销毁FlyAction,上文说过了。此外,还要对数据列表进行删除:
public void Clear(){ foreach(DiskData d in used){ //由于某些对象可能还被FlyAction绑定,因此需要先让FlyAction结束并回调,再销毁对象。 d.gameObject.transform.position = new Vector3(0, -20, 0); Destroy(d.gameObject); } foreach(DiskData d in free){ d.gameObject.transform.position = new Vector3(0, -20, 0); Destroy(d.gameObject); } //由于对象被销毁了,因此这些数据列表也应当删除。 used.Clear(); free.Clear(); }
动作接口和基类
直接重用之前的代码即可:
public enum SSActionEventType : int { Started, Competed }
public interface ISSActionCallback
{
//回调函数
void SSActionEvent(SSAction source,
SSActionEventType events = SSActionEventType.Competed,
int intParam = 0,
string strParam = null,
Object objectParam = null);
}
public class SSAction : ScriptableObject {
public bool enable = true; //是否可进行
public bool destroy = false; //是否已完成
public GameObject gameobject; //动作对象
public Transform transform; //动作对象的transform
public ISSActionCallback callback; //回调函数
/*防止用户自己new对象*/
protected SSAction() { }
public virtual void Start() {
throw new System.NotImplementedException();
}
public virtual void Update() {
throw new System.NotImplementedException();
}
}
动力学动作和运动学动作
运动学动作只需要将属性Kinematic
打开即可。但需要注意,由于没有重力影响,出界条件也会变成四个方向的出界判定:
public class CCFlyAction : SSAction
{
float speed;
Vector3 direction;
public static CCFlyAction GetSSAction(Vector3 dir, float speed){
CCFlyAction ret = ScriptableObject.CreateInstance<CCFlyAction>();
ret.speed = speed;
ret.direction = dir;
return ret;
}
// Update is called once per frame
public override void Update()
{
//动作运行
transform.Translate(direction * speed * Time.deltaTime);
//判断动作是否结束,结束则进行回调。(注意,由于不存在重力,这里出屏幕外就算结束)
if(this.transform.position.y < -10 || this.transform.position.y > 20 || this.transform.position.x < -35 || this.transform.position.x > 35){
this.destroy = true;
this.enable = false;
this.callback.SSActionEvent(this);
}
}
public override void Start() {
gameobject.GetComponent<Rigidbody>().isKinematic = true;
}
}
动力学也是这些地方与运动学不同:
public class PhysicalAction : SSAction
{
float speed;
Vector3 direction;
public static PhysicalAction GetSSAction(Vector3 dir, float speed){
PhysicalAction ret = ScriptableObject.CreateInstance<PhysicalAction>();
ret.speed = speed;
ret.direction = dir;
return ret;
}
// Update is called once per frame
public override void Update()
{
//动作运行
transform.Translate(direction * speed * Time.deltaTime);
//判断动作是否结束,结束则进行回调。
if(this.transform.position.y < -10){
this.destroy = true;
this.enable = false;
this.callback.SSActionEvent(this);
}
}
public override void Start() {
gameobject.GetComponent<Rigidbody>().isKinematic = false;
//为物体增加水平初速度,否则会由于受到重力严重影响运动状态。
gameobject.GetComponent<Rigidbody>().velocity = speed * direction;
}
}
动作管理接口与基类
基类复用之前的代码(篇幅原因这里不写了)。
接口只需要定义一个函数,这样就可以采用Adapter模式来适应不同的接口变化了:
public interface IActionManager
{
void Fly(GameObject disk, float speed, Vector3 direction);
}
动力学动作管理和运动学动作管理
运动学:
public class CCActionManager : SSActionManager, ISSActionCallback, IActionManager
{
public CCFlyAction flyAction;
public FirstController controller;
public void Start()
{
controller = (FirstController)SSDirector.GetInstance().CurrentScenceController;
}
//对外接口
public void Fly(GameObject disk, float speed, Vector3 direction){
flyAction = CCFlyAction.GetSSAction(direction, speed);
RunAction(disk, flyAction, this);
}
//回调函数
public void SSActionEvent(SSAction source,
SSActionEventType events = SSActionEventType.Competed,
int intParam = 0,
string strParam = null,
Object objectParam = null)
{
controller.diskFactory.FreeDisk(source.gameobject);//动作结束,交给工厂来释放资源
}
}
动力学与运动学基本相似,只是将CCFlyAction
修改为PhysicalAction
即可。
辅助类
记分员:
public class ScoreRecorder : System.Object{
public int score;
public void Record(GameObject disk){
score += disk.GetComponent<DiskData>().points;
}
public void Reset(){
score = 0;
}
}
实验结果
具体的实验结果请参考展示视频