Unity3D -- Hit UFO

一、游戏内容

编写一个简单的鼠标打飞碟(Hit UFO)游戏

游戏规则
1. 游戏有 n 个 round,每个 round 都包括10 次 trial;
2. 每个 trial 的飞碟的色彩、大小、发射位置、速度、角度、同时出现的个数都可能不同。它们由该 round 的 ruler 控制;
3. 每个 trial 的飞碟有随机性,总体难度随 round 上升;
4. 鼠标点中得分,得分规则按色彩、大小、速度不同计算,规则可自由设定。

附加要求
1. 使用带缓存的工厂模式管理不同飞碟的生产与回收,该工厂必须是场景单实例的!具体实现见参考资源 Singleton 模板类
2. 尽可能使用前面 MVC 结构实现人机交互与游戏模型分离

基于以上要求,详细的游戏规则如下:

  • 飞碟:游戏中共创建了三种飞碟,按照难易程度设置其大小和飞行速度
  • 分数:三种飞碟按照难易程度,击中后得到的分数分别为3,2,1
  • 血量:玩家的初始血量为10,每当一个飞碟飞出视野后玩家血量减一
  • 关卡:游戏共设置3个关卡,每个关卡中都有10波飞碟来袭。关卡一同时会出现3个飞碟,关卡二同时有5个飞碟,关卡三有8个飞碟。飞碟的飞行速度也会随着关卡增高而有所增加。
  • 胜利:成功通过三个关卡
  • 失败:玩家的血量被清零

二、UML图

在这里插入图片描述

三、游戏的实现

1. DiskFactory

飞碟工厂实现对不同飞碟的生产、管理以及回收。需要注意的是,这里使用的是带缓存的单实例工厂模式。

  • 单实例:运用模板,可以为每个 MonoBehaviour子类创建一个对象的实例: Singleten<T>
  • 带缓存的工厂:由于对飞碟的多次创建与销毁开销很大,所以我们使用带缓存的工厂来避免频繁的创建与销毁操作。当一个飞碟需要被销毁时,工厂并不直接销毁他,而是将其标记为“空闲”,表示他已经不被场景使用了。在新的飞碟被需要时,工厂不会直接去实例化一个新的飞碟,而是从被标记“空闲”的飞碟中选择可用的实例,只有在当前没有可用的实例时,工厂才会去真正实例化一个新的飞碟。这样一来就能减少游戏不断创建与销毁游戏对象的极大开销。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class DiskModel{
    public GameObject disk;
    public int type;    //飞碟的类型,根据类型实例化预制的飞碟
    public int ID;      //飞碟的标识
    public int score;   //击中飞碟后可得的分数
    //根据飞碟类型和标识ID来创建飞碟
    public DiskModel(int type, int ID) {
        disk = Object.Instantiate(Resources.Load<GameObject>("disk"+type.ToString()), Vector3.zero, Quaternion.identity);
        this.type = type;
        this.ID = ID;
        score = type + 1;
    }
    public int getDiskID() {
        return ID;
    }public void setDiskID(int ID) {
    this.ID = ID;
}public int getType() {
    return type;
    }
}

public class DiskFactory : MonoBehaviour {
    private int diskID = 0;
    private List<DiskModel>[] disks = new List<DiskModel>[3]; //共有三种飞碟
    bool[,] diskStatus = new bool[3, 20];   //每种飞碟最多有二十个
    int[] size = new int[3];    //保存当前已创建出来的每种飞碟的数量private List<DiskModel> ddisk = new List<DiskModel>();public void Start() {
    for (int i = 0; i < 3; i++) {
        size[i] = 0;                                 
        disks[i] = new List<DiskModel>();
    }
    for (int j = 0; j < 3; j++) {
        for (int i = 0; i < 20; i++) {
            diskStatus[j, i] = true;
        }
    }
}//随机获取一种空闲飞碟,如果飞碟不足则新建一个实例
public DiskModel getDisk() {//随机从三种预制中选择一个飞碟外观
    int type = Random.Range(0, 3);
    DiskModel disk;//尝试获取已经被创建但目前处于空闲态的飞碟
    for (int i = 0; i < size[type]; i++) {
        if (diskStatus[type, i] == false) {
            diskStatus[type, i] = true;
            disks[type][i].disk.SetActive(true);
            disk = disks[type][i];
            disk.setDiskID(diskID);
            diskID++;
            //取出时飞碟不能够有爆炸特效
            disk.disk.GetComponent<ParticleSystem>().Stop();
            return disk;
        }
    }//当前没有可用的空闲飞碟,需要创建
    disk = new DiskModel(type, diskID);
    diskID++;
    disks[type].Add(disk);
    diskStatus[type, size[type]] = true;
    size[type]++;
    disk.disk.GetComponent<ParticleSystem>().Stop();
    return disk;
}//回收飞碟
public void FreeDisk(DiskModel disk) {
    int type = disk.getType();
    int ID = disk.getDiskID();for (int i = 0; i < size[type]; i++) {
        if (disk.getDiskID() == disks[type][i].getDiskID()) {
            diskStatus[type, i] = false;
            return;
        }
    }
}//根据 gameobject 的 instanceID 来查找飞碟
public DiskModel findDisk(int InstanceID) {
    for(int i = 0; i < 3; i++) {
        for(int j = 0; j < size[i]; j++) {
            if (disks[i][j].disk.GetInstanceID() == InstanceID) return disks[i][j];
        }
    }
    return null;
}}

2. SSAction

基类 SSAction在上一篇博客已经介绍过,这里只介绍本次使用的子类 FlyAction
FlyAction实际上只是一个简单的直线动作,但是与CCMoveAction不同的是其参数是飞行的角度和速度。

public class FlyAction : SSAction{                           
    private Vector3 angle;
    private float speed;//根据飞行角度和速度获取一个FlyAction
public static FlyAction GetSSAction(Vector3 angle, float speed) {
    FlyAction action = CreateInstance<FlyAction>();
    action.angle = angle;
    action.speed = speed;
    return action;
}//实现简单的直线飞行
public override void Update() {
    transform.position += angle * Time.deltaTime * speed;
}public override void Start() {
    Update();
}}

3. SSActionManager

FlyActionManager 是本次游戏的动作管理者,管理此次控制飞碟运动的多个Action。

FlyActionManager 具有的功能:

  • 使用Ruler为每个 Action 设置合适的飞行角度和速度
  • 为输入的飞碟寻找一个空闲的 Action 进行飞行动作
  • 在每次 Update 时 FlyActionManager 对被使用的所有 action 进行 Update 调用
public class FlyActionManager : SSActionManager {
    public FlyAction[] actions = new FlyAction[20]; //最多建立20个action
    public int[] diskID = new int[20];              //action 所应用的飞碟的ID
    public ISceneController controller;            
    private Ruler ruler;    //根据不同 round 对飞碟的性能参数做设置
    int round;
    protected void Start() {
    controller = (ISceneController)SSDirector.getInstance().currentSceneController;
    controller.actionManager = this;
    ruler = new Ruler();
    for(int i = 0; i < 20; i++) {
        diskID[i] = -1; //开始时没有可用飞碟,ID 均为-1
    }
}public void Update() {
    for(int i = 0; i < 20; i++) {
        if (diskID[i] != -1) {
            //执行有附着在飞碟上的 action
            actions[i].Update();
        }
    }
}public void setRound(int round) {
    this.round = round;
}public void UFOFly(DiskModel disk) {
    ruler.setRound(round);
    disk.disk.transform.position = ruler.getStart();//设置飞碟的出现位置
    int index = 0;
    for (; diskID[index] != -1; index++) ;//找到空闲的 Action
    actions[index] = FlyAction.GetSSAction(ruler.getAngle(), ruler.getSpeed());
    diskID[index] = disk.getDiskID();
    this.RunAction(disk.disk, actions[index], this);
}public void freeAction(DiskModel disk) {
    for(int i = 0; i < 20; i++) {
        //当飞碟不再需要时,actionManager可以简单的将对应的action设为空闲,而非直接删除 action
        if(diskID[i] == disk.getDiskID()) {
            diskID[i] = -1;
            break;
        }
    }
}
}

//为飞碟设置合适的性能参数
public class Ruler {
    private int round;
    public void setRound(int round) {
        this.round = round;
    }//获得飞碟出现的位置
public Vector3 getStart() {
    int x = Random.Range(-25, 25);  //相机能够看到的位置
    int y = Random.Range(-25, 25);  //相机能够看到的位置
    int z = Random.Range(-5, 5);    //将位置局限在(-5.5),以免由于 z 距离过远影响游戏体验
    return new Vector3(x, y, z);
}public Vector3 getAngle() {
    int xFlag = Random.Range(0, 2);
    int yFlag = Random.Range(0, 2);
    float x = Random.Range(0, 0.50f);//angle_x属于(0,0.5)
    float y = 1 - x;                 //angle_y = 1-x
    float z = 0;    //将z设为0使飞碟的运动轨迹始终保持在x-y平面上,有利于游戏体验
    if (xFlag == 1) x *= -1;    //随机将角度设为负数
    if (xFlag == 1) y *= -1;
    return new Vector3(x, y, z);
}//设置速度
public float getSpeed() {
    //飞碟速度随着round增加而增加
    return 5 + round * 3;
}}

4. ISceneController

ISceneController通过使用飞碟工厂以及动作管理器来实现对游戏的全局把控。

其主要功能为:

  • 在飞碟数量小于当前 round 值规定时,向场景中发送飞碟;
  • 对场景中所有飞碟进行位置判断,如果超出视野范围则“销毁”飞碟;
  • 判断玩家的射击操作,击中飞碟后进行爆炸特效、增加分数、销毁飞碟等一系列处理;
//IUserAction有获取分数、血量、trail、round,以及启动游戏的功能
public interface IUserAction {
    int getScore();
    int getLife();
    int getTrail();
    int getRound();
    void startGame();
}

public class ISceneController : MonoBehaviour, IUserAction {
    public FlyActionManager actionManager;
    public DiskFactory diskFactory;
    private List<DiskModel> currentDisks = new List<DiskModel>();
    private int diskCount = 0;  //当前场景中的飞碟数量
    private int[] maxCount = { 3, 5, 5 };   //每个round中飞碟需要维持的数目,数目不足时将发送新的飞碟
    private int round = 0;  //当前游戏的 round
    private int currentTrial = 0;//当前游戏的 trail
    private int score = 0;  //获得的分数
    private int[] scoreOfRound = { 5, 5, 5 };   //每个round需要达成的分数目标
    private bool playing = false;
    private int life = 10;  //血量public UserGUI userGUI;void Start() {
    SSDirector director = SSDirector.getInstance();
    director.currentSceneController = this;
    diskFactory = Singleton<DiskFactory>.Instance;
    actionManager = gameObject.AddComponent<FlyActionManager>() as FlyActionManager;
    actionManager.setRound(round);
    userGUI = gameObject.AddComponent<UserGUI>() as UserGUI;
}void Update() {
    //发送飞碟
    if (playing) {
        if (diskCount < maxCount[round])
            sendDisk();
        //检查当前游戏状态是否允许升级
        checkStatus();
        removeRemoteDisk();//检查玩家的射击操作
        if (Input.GetButtonDown("Fire1")) {
            Vector3 mp = Input.mousePosition; 
            Hit(mp);
        }
    }
}public void startGame() {
    life = 10;
    playing = true;
}public int getScore() {
    return score;
}public int getLife() {
    return life;
}public int getTrail() {
    return currentTrial;
}public int getRound() {
    return round;
}//检查当前游戏状态
//检查当前trail是否足以进入下一 round
//检查当前round是否足够结束游戏
public void checkStatus() {
    //此时的分数大于设置的阈值,游戏进入下一阶段,分数清零重新计算
    if (score >= scoreOfRound[round]) {
        currentTrial++;
        score = 0;
        //当游戏的trail大于3时进入下一 round
        if (currentTrial >= 3) {
            round++;
            life = 10;//当游戏进入到新的round生命值回复
            if (round >= 3) winGame();
            currentTrial = 0;              
            actionManager.setRound(round);
        }
    }
}//判断飞碟是否已经离开视野
private bool outOfSight(Vector3 pos) {
    return pos.x > 35 || pos.x < -35
        || pos.y > 35 || pos.y < -35
        || pos.z > 10 || pos.z < -300;
}//检查当前所有被使用的飞碟是否已经飞出视野
//将飞出视野的飞碟“销毁”
//每销毁一个飞碟就将当前飞碟数量减一,游戏将自动补齐缺少的飞碟
private void removeRemoteDisk() {
    for (int i = 0; i < diskCount; i++) {
        GameObject tmp = currentDisks[i].disk;
        if (outOfSight(tmp.transform.position)) {
            tmp.SetActive(false);
            diskFactory.FreeDisk(currentDisks[i]);
            actionManager.freeAction(currentDisks[i]);
            currentDisks.Remove(currentDisks[i]);
            diskCount--;
            life--;
        }
    }
}//发送飞碟
private void sendDisk() {
    diskCount++;
    DiskModel disk = diskFactory.getDisk(); //从工厂获取新的飞碟
    currentDisks.Add(disk);                 //将新飞碟加入到当前的列表
    actionManager.UFOFly(disk);             //令飞碟进行移动
}//检查玩家是否射中飞碟
public void Hit(Vector3 pos) {
    Ray ray = Camera.main.ScreenPointToRay(pos);
    RaycastHit[] hits;
    hits = Physics.RaycastAll(ray);
    DiskModel hitDisk;for (int i = 0; i < hits.Length; i++) {
        RaycastHit hit = hits[i];
        hitDisk = diskFactory.findDisk(hit.collider.gameObject.GetInstanceID());//射线打中物体
        if (hitDisk != null) {
            score += hitDisk.score;//显示爆炸粒子效果
            hitDisk.disk.GetComponent<ParticleSystem>().Play();//等0.5秒后执行回收飞碟
            StartCoroutine(WaitingParticle(0.50f, diskFactory, hitDisk));
            break;
        }
    }
}public void winGame() {
    playing = false;
    Debug.Log("you win");
}//暂停几秒后回收飞碟
IEnumerator WaitingParticle(float wait_time, DiskFactory diskFactory, DiskModel hitDisk) {
    yield return new WaitForSeconds(wait_time);
    hitDisk.disk.SetActive(false);
    hitDisk.disk.GetComponent<ParticleSystem>().Stop();
    currentDisks.Remove(hitDisk);
    actionManager.freeAction(hitDisk);
    diskFactory.FreeDisk(hitDisk);
    diskCount--;
}}

5. UserGUI

UI的作用比较简单,主要是实现显示当前分数、血量、关卡,以及在关卡之间切换的功能。我在这里主要是参考了师兄师姐的实现,然后按照自己的规则做了一些改动。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class UserGUI : MonoBehaviour {
    private IUserAction action;//每个GUI的style
    GUIStyle bold_style = new GUIStyle();
    GUIStyle text_style = new GUIStyle();
    GUIStyle over_style = new GUIStyle();
    bool show;
    int round;
    bool changeRound;
    bool playing = true;void Start() {
    show = true;
    changeRound = false;
    action = SSDirector.getInstance().currentSceneController as IUserAction;
}void OnGUI() {
    if (!playing) {
        if (action.getLife() < 0) {
            GUI.Button(new Rect(0, 0, Screen.width, Screen.width), "YOU LOSE");
        }
        else {
            GUI.Button(new Rect(0, 0, Screen.width, Screen.width), "YOU WIN");
        }
        return;
    }
​
    bold_style.normal.textColor = new Color(1, 0, 0);
    bold_style.fontSize = 16;
    text_style.normal.textColor = new Color(0, 0, 0, 1);
    text_style.fontSize = 16;
    over_style.normal.textColor = new Color(1, 0, 0);
    over_style.fontSize = 25;if (action.getLife() < 0) {
        playing = false;
    }if (changeRound) {
        GUI.Label(new Rect(Screen.width / 2 - 120, Screen.width / 2 - 220, 400, 100), " N E X T   R  O U N D ", over_style);
        if (GUI.Button(new Rect(0, 0, Screen.width, Screen.width), "press to continue")) {
            changeRound = false;
            action.startGame();
        }
    }
    else {
        if (show) {
            GUI.Label(new Rect(Screen.width / 2 - 170, Screen.width / 2 - 180, 400, 100), "大量UFO出现,点击它们即可消灭,快来加入战斗吧", text_style);
            if (GUI.Button(new Rect(Screen.width / 2 - 40, Screen.width / 2 - 120, 100, 50), "开始")) {
                show = false;
                action.startGame();
            }
        }
        else {
            GUI.Label(new Rect(Screen.width - 120, 20, 200, 50), "score:", text_style);
            GUI.Label(new Rect(Screen.width - 70, 20, 200, 50), action.getScore().ToString(), bold_style);
​
            GUI.Label(new Rect(Screen.width - 120, 50, 200, 50), "trial:", text_style);
            GUI.Label(new Rect(Screen.width - 70, 50, 200, 50), (action.getTrail() + 1).ToString(), bold_style);
​
            GUI.Label(new Rect(Screen.width - 120, 80, 50, 50), "life:", text_style);
            GUI.Label(new Rect(Screen.width - 70, 80, 50, 50), action.getLife().ToString(), bold_style);if (action.getRound() > round) {
                round = action.getRound();
                if (round > 2) playing = false;
                changeRound = true;
            }
        }
    }
}}

6. 自定义组件

自定义组件需要我们新建一个脚本,并且继承Editor,在类的前面添加:[CustomEditor(typeof(DiskData))],这里的DiskData就是要实现自定义组件的类。在这之后添加[CanEditMultipleObjects],实现了多个对象可以不同的修改。如果没有这个标签,那么在Inspector修改之后,拥有这个DiskData作为组件的预制体所有修改都会同步。SerializedProperty是我们需要序列化的属性,通过EditorGUILayout的不同的方法,可以在Inspector中用不同方式呈现我们序列化的属性。序列化的属性的呈现方式需要在OnInspectorGUI中进行编写。

  • 自定义组件的实现:
//脚本DiskData
public class DiskData : MonoBehaviour
{
    public int score = 1;                               //射击此飞碟得分
    public Color color = Color.white;                   //飞碟颜色
    public Vector3 direction;                           //飞碟初始的位置
    public Vector3 scale = new Vector3( 1 ,0.25f, 1);   //飞碟大小
}//脚本MyDiskEditor
[CustomEditor(typeof(DiskData))]
[CanEditMultipleObjects]
public class MyDiskEditor: Editor
{
    SerializedProperty score;                              //分数
    SerializedProperty color;                              //颜色
    SerializedProperty scale;                              //大小void OnEnable()
{
    //序列化对象后获得各个值
    score = serializedObject.FindProperty("score");
    color = serializedObject.FindProperty("color");
    scale = serializedObject.FindProperty("scale");
}public override void OnInspectorGUI()
{
    //更新serializedProperty,始终在OnInspectorGUI的开头执行此操作
    serializedObject.Update();
    //设置滑动条
    EditorGUILayout.IntSlider(score, 0, 5, new GUIContent("score"));if (!score.hasMultipleDifferentValues)
    {
        //显示进度条
        ProgressBar(score.intValue / 5f, "score");
    }
    //显示值
    EditorGUILayout.PropertyField(color);
    EditorGUILayout.PropertyField(scale);
    //将更改应用于serializedProperty,始终在OnInspectorGUI的末尾执行此操作
    serializedObject.ApplyModifiedProperties();
}
private void ProgressBar(float value, string label)
{
    Rect rect = GUILayoutUtility.GetRect(18, 18, "TextField");
    EditorGUI.ProgressBar(rect, value, label);
    EditorGUILayout.Space();
}}


四、 运行界面与代码传送门

PS:为了使游戏尽早完成进行展示视频中trial的个数被设置为3
在这里插入图片描述
在这里插入图片描述
游戏视频地址
代码传送门

五、知识点

  • c# 中二维数组和交错数组

二维数组是按照你定义的类型的一组数,比如int [2,3]那就是说一个两行三列,每一个元素都是>一个整型数的数组,但是交错数组int[2][],意思是这个数组有两个元素,每一个元素都是一个>整型的数组,但是长度可以不一样,比如int [][] arr= new int[2][];因为每个数组的元素不一样,所>以后面的[]不能填值。
int [0][]=new int[10];
int [1][]=new int[8];

int arr = new int3{     
	new int[2]{1,2},    
	new int[3]{3,4,5},     
	new int[4]{6,7,8,9}
}; 
foreach (var item in arr)//最外层得到每个数组
{  
	foreach (var i in item)//内层是去每个数组中访问元素  
	{       
		Console.Write(i);   
	}   
	Console.WriteLine();
} 
  • 随机数 Random.Range(Min,Max) 的使用

static function Range (min : float, max : float) : float
当Range的参数是float时,返回一个在Min和max之间的随机浮点数,区间为[min,max]

function Range (min : int, max : int) : int
当Range的参数是整型时,返回一个在Min和max之间的随机整数,区间为[min,max)

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值