Unity3d学习之路-简单打飞碟小游戏

简单打飞碟小游戏


游戏规则与游戏要求

  • 规则
    鼠标点击飞碟,即可获得分数,不同飞碟分数不一样,飞碟的初始位置与飞行速度随机,随着分数增加,游戏难度增加。初始时每个玩家都有6条生命,漏打飞碟扣除一条生命,直到生命为0游戏结束。

  • 要求:

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

    • 用自定义组件定义几种飞碟,编辑并赋予飞碟一些属性,做成预制

游戏UML类图

  • 很多类都是使用之前的代码,修改过或者新建的类,在UML中表示出来了
    UML类图

游戏实现过程

动作部分

  • FlyActionManager

    飞碟飞行的动作管理类,当场景控制器需要飞碟飞行的时候,调用动作管理类的方法,让飞碟飞行

public class FlyActionManager : SSActionManager
{

    public UFOFlyAction fly;                            //飞碟飞行的动作
    public FirstController scene_controller;             //当前场景的场景控制器

    protected void Start()
    {
        scene_controller = (FirstController)SSDirector.GetInstance().CurrentScenceController;
        scene_controller.action_manager = this;     
    }
    //飞碟飞行
    public void UFOFly(GameObject disk, float angle, float power)
    {
        fly = UFOFlyAction.GetSSAction(disk.GetComponent<DiskData>().direction, angle, power);
        this.RunAction(disk, fly, this);
    }
}
  • UFOFlyAction

    给飞碟一个方向和一个力,然后飞碟模拟做有向下加速度的飞行动作,直到飞碟不在相机范围内,就停止动作。可以设定飞碟位置y方向的值,当它小于多少的时候,不再飞行,然后等待场景控制器和飞碟工厂进行配合回收飞碟。其实可以使用Rigidbody组件,开启Unity的物理模拟效果。

public class UFOFlyAction : SSAction
{
    public float gravity = -5;                                 //向下的加速度
    private Vector3 start_vector;                              //初速度向量
    private Vector3 gravity_vector = Vector3.zero;             //加速度的向量,初始时为0
    private float time;                                        //已经过去的时间
    private Vector3 current_angle = Vector3.zero;               //当前时间的欧拉角

    private UFOFlyAction() { }
    public static UFOFlyAction GetSSAction(Vector3 direction, float angle, float power)
    {
        //初始化物体将要运动的初速度向量
        UFOFlyAction action = CreateInstance<UFOFlyAction>();
        if (direction.x == -1)
        {
            action.start_vector = Quaternion.Euler(new Vector3(0, 0, -angle)) * Vector3.left * power;
        }
        else
        {
            action.start_vector = Quaternion.Euler(new Vector3(0, 0, angle)) * Vector3.right * power;
        }
        return action;
    }

    public override void Update()
    {
        //计算物体的向下的速度,v=at
        time += Time.fixedDeltaTime;
        gravity_vector.y = gravity * time;

        //位移模拟
        transform.position += (start_vector + gravity_vector) * Time.fixedDeltaTime;
        current_angle.z = Mathf.Atan((start_vector.y + gravity_vector.y) / start_vector.x) * Mathf.Rad2Deg;
        transform.eulerAngles = current_angle;

        //如果物体y坐标小于-10,动作就做完了
        if (this.transform.position.y < -10)
        {
            this.destroy = true;
            this.callback.SSActionEvent(this);      
        }
    }

    public override void Start() { }
}

飞碟预制与重用部分

  • 用自定义组件定义几种飞碟

使用序列化的方法,将DiskData的属性显示在Inspector中。需要我们新建一个脚本,并且继承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();
    }
}

如果自定义组件只是运用在一个预制体上,也可以使用其他方法简化代码,具体操作请看官方文档。我设计了三种飞碟,把DiskData脚本挂载在每个飞碟预制体上就可以对它们进行设置了,最后实现效果如下图。

自定义组件

  • DiskFactory

飞碟工厂类,根据回合的不同实现随机发送不同的飞碟。在场景控制器需要某种飞碟的时候,飞碟工厂从仓库(List <DiskData> free)中获取这种飞碟,如果仓库中没有,则新的实例化一个飞碟,然后添加到正在使用的飞碟列表中。当场景控制器发现飞碟被打中或者飞碟掉出摄像机视野外,将执行回收飞碟。(ps: 这里也留了一个问题为啥List中保存DiskData,而不直接保存GameObject,我觉得是DiskData可以用gameObject属性直接得到GameObject对象,而GameObject对象要用GetComponent<DiskData>()方法去查找DiskData组件,而GetComponent<>()比直接获取属性慢的多,使用DiskData频率可能高于直接使用GameObject对象,所以优选保存DiskData)。。。然而获取飞碟的时候我返回了GameObject,而且在场景控制器我也是保存的GameObject :)

public class DiskFactory : MonoBehaviour
{
    public GameObject disk_prefab = null;                 //飞碟预制体
    private List<DiskData> used = new List<DiskData>();   //正在被使用的飞碟列表
    private List<DiskData> free = new List<DiskData>();   //空闲的飞碟列表

    public GameObject GetDisk(int round)
    {
        int choice = 0;
        int scope1 = 1, scope2 = 4, scope3 = 7;           //随机的范围
        float start_y = -10f;                             //刚实例化时的飞碟的竖直位置
        string tag;
        disk_prefab = null;

        //根据回合,随机选择要飞出的飞碟
        if (round == 1)
        {
            choice = Random.Range(0, scope1);
        }
        else if(round == 2)
        {
            choice = Random.Range(0, scope2);
        }
        else
        {
            choice = Random.Range(0, scope3);
        }
        //将要选择的飞碟的tag
        if(choice <= scope1)
        {
            tag = "disk1";
        }
        else if(choice <= scope2 && choice > scope1)
        {
            tag = "disk2";
        }
        else
        {
            tag = "disk3";
        }
        //寻找相同tag的空闲飞碟
        for(int i=0;i<free.Count;i++)
        {
            if(free[i].tag == tag)
            {
                disk_prefab = free[i].gameObject;
                free.Remove(free[i]);
                break;
            }
        }
        //如果空闲列表中没有,则重新实例化飞碟
        if(disk_prefab == null)
        {
            if (tag == "disk1")
            {
                disk_prefab = Instantiate(Resources.Load<GameObject>("Prefabs/disk1"), new Vector3(0, start_y, 0), Quaternion.identity);
            }
            else if (tag == "disk2")
            {
                disk_prefab = Instantiate(Resources.Load<GameObject>("Prefabs/disk2"), new Vector3(0, start_y, 0), Quaternion.identity);
            }
            else
            {
                disk_prefab = Instantiate(Resources.Load<GameObject>("Prefabs/disk3"), new Vector3(0, start_y, 0), Quaternion.identity);
            }
            //给新实例化的飞碟赋予其他属性
            float ran_x = Random.Range(-1f, 1f) < 0 ? -1 : 1;
            disk_prefab.GetComponent<Renderer>().material.color = disk_prefab.GetComponent<DiskData>().color;
            disk_prefab.GetComponent<DiskData>().direction = new Vector3(ran_x, start_y, 0);
            disk_prefab.transform.localScale = disk_prefab.GetComponent<DiskData>().scale;
        }
        //添加到使用列表中
        used.Add(disk_prefab.GetComponent<DiskData>());
        return disk_prefab;
    }

    //回收飞碟
    public void FreeDisk(GameObject disk)
    {
        for(int i = 0;i < used.Count; i++)
        {
            if (disk.GetInstanceID() == used[i].gameObject.GetInstanceID())
            {
                used[i].gameObject.SetActive(false);
                free.Add(used[i]);
                used.Remove(used[i]);
                break;
            }
        }
    }
}

飞碟游戏场景控制器

  • FirstController

    游戏有三种状态,游戏开始,游戏中,游戏结束。(最好用枚举吧,我用了三个bool变量…)。游戏开始:设置一个定时器,定时从工厂那里获取飞碟,并且发送飞碟,一次只拿一个并立即发送。(有看过师兄的代码是一次就从工厂拿多个,然后慢慢发送飞碟,如果飞碟完了又一次性拿多个)。游戏中:根据定时器,时间到了就获取飞碟并发送。当分数到达10分,将会多增加一种飞碟随机发送这两种并且缩短发送间隔,达到25分则三种飞碟随机发送,发送概率不一样。游戏结束:显示最高分,提供重新开始按钮。

    • 用户点击屏幕发送射线

      如果打中则激活爆炸粒子效果,一段时间后,飞碟工厂再回收。这里使用了协程的概念,我觉得和多线程的意思很像,StartCoroutine开启一个协程,StartCoroutine后面的代码和新的协程一起执行,使用yield暂停协程的执行,yield return的值是代表什么时候继续协程的执行,这样在yield return后面的代码将延迟一点时间执行。更详细的解释请看官方文档。(ps: 这里有一点迷的就是,加上协程后,用户点击一次屏幕会执行两次Hit函数,所以我用了判断物体是否被打中,打中了就不要再继续执行后面代码)

    • 发送飞碟

      从飞碟队列中取出一个飞碟,然后重新设置它的位置(因为拿到的可能是使用过的飞碟)。发送的时候检测未射中的飞碟列表,是否已经飞出镜头外了,如果是用户减一条生命。这里直接调用了user_gui的方法,其实有点违背了MVC,所以如果要改进可以让计分员记录生命,这样可以合并为一个场景中专门记录数值的类。

部分代码如下:

 void Start ()
 {
     SSDirector director = SSDirector.GetInstance();     
     director.CurrentScenceController = this;             
     disk_factory = Singleton<DiskFactory>.Instance;
     score_recorder = Singleton<ScoreRecorder>.Instance;
     action_manager = gameObject.AddComponent<FlyActionManager>() as FlyActionManager;
     user_gui = gameObject.AddComponent<UserGUI>() as UserGUI;
 }
 //发射射线
public void Hit(Vector3 pos)
{
    Ray ray = Camera.main.ScreenPointToRay(pos);
    RaycastHit[] hits;
    hits = Physics.RaycastAll(ray);
    bool not_hit = false;
    for (int i = 0; i < hits.Length; i++)
    {
        RaycastHit hit = hits[i];
        //射线打中物体
        if (hit.collider.gameObject.GetComponent<DiskData>() != null)
        {
            //射中的物体要在没有打中的飞碟列表中
            for (int j = 0; j < disk_notshot.Count; j++)
            {
                if (hit.collider.gameObject.GetInstanceID() == disk_notshot[j].gameObject.GetInstanceID())
                {
                    not_hit = true;
                }
            }
            if(!not_hit)
            {
                return;
            }
            disk_notshot.Remove(hit.collider.gameObject);
            //记分员记录分数
            score_recorder.Record(hit.collider.gameObject);
            //显示爆炸粒子效果
            Transform explode = hit.collider.gameObject.transform.GetChild(0);
            explode.GetComponent<ParticleSystem>().Play();
            //等0.08秒后执行回收飞碟
            StartCoroutine(WaitingParticle(0.08f, hit, disk_factory, hit.collider.gameObject));
            break;
        }
    }
}
//暂停几秒后回收飞碟
IEnumerator WaitingParticle(float wait_time, RaycastHit hit, DiskFactory disk_factory, GameObject obj)
{
    yield return new WaitForSeconds(wait_time);
    //等待之后执行的动作  
    hit.collider.gameObject.transform.position = new Vector3(0, -9, 0);
    disk_factory.FreeDisk(obj);
}

//发送飞碟
private void SendDisk()
{
    float position_x = 16;                       
    if (disk_queue.Count != 0)
    {
        GameObject disk = disk_queue.Dequeue();
        disk_notshot.Add(disk);
        disk.SetActive(true);
        //设置被隐藏了或是新建的飞碟的位置
        float ran_y = Random.Range(1f, 4f);
        float ran_x = Random.Range(-1f, 1f) < 0 ? -1 : 1;
        disk.GetComponent<DiskData>().direction = new Vector3(ran_x, ran_y, 0);
        Vector3 position = new Vector3(-disk.GetComponent<DiskData>().direction.x * position_x, ran_y, 0);
        disk.transform.position = position;
        //设置飞碟初始所受的力和角度
        float power = Random.Range(10f, 15f);
        float angle = Random.Range(15f, 28f);
        action_manager.UFOFly(disk,angle,power);
    }

    for (int i = 0; i < disk_notshot.Count; i++)
    {
        GameObject temp = disk_notshot[i];
        //飞碟飞出摄像机视野也没被打中
        if (temp.transform.position.y < -10 && temp.gameObject.activeSelf == true)
        {
            disk_factory.FreeDisk(disk_notshot[i]);
            disk_notshot.Remove(disk_notshot[i]);
            //玩家血量-1
            user_gui.ReduceBlood();
        }
    }
}

一般射击都是从点击处射出子弹,带有碰撞器,当碰撞器与飞碟碰撞器触碰时候,应该是开启子弹上的粒子效果才对,然后播放完毕后子弹消失。


记分员

  • ScoreRecorder

记录分数和重置分数

public class ScoreRecorder : MonoBehaviour
{
    public int score;                   //分数
    void Start ()
    {
        score = 0;
    }
    //记录分数
    public void Record(GameObject disk)
    {
        int temp = disk.GetComponent<DiskData>().score;
        score = temp + score;
        //Debug.Log(score);
    }
    //重置分数
    public void Reset()
    {
        score = 0;
    }
}

游戏实现截图

开始界面

游戏中

游戏结束

小结

这次游戏使用工厂对象实现了预制体实例化后的重用,提高了游戏性能,还加入了获取鼠标输入,增加了与用户的交互。在写博客的过程中发现了很多代码中存在的问题,也发现了许多游戏中需要改进的地方。

完整项目请点击传送门,Assets/Scenes/中的myScene是本次游戏场景

阅读更多
想对作者说点什么?

博主推荐

换一批

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