1、编写一个简单的鼠标打飞碟(Hit UFO)游戏
· 游戏内容要求:
游戏有 n 个 round,每个 round 都包括10 次 trial;
每个 trial 的飞碟的色彩、大小、发射位置、速度、角度、同时出现的个数都可能不同。它们由该 round 的 ruler 控制;
每个 trial 的飞碟有随机性,总体难度随 round 上升;
鼠标点中得分,得分规则按色彩、大小、速度不同计算,规则可自由设定。
· 游戏的要求:
使用带缓存的工厂模式管理不同飞碟的生产与回收,该工厂必须是场景单实例的!具体实现见参考资源 Singleton 模板类
近可能使用前面 MVC 结构实现人机交互与游戏模型分离
游戏规则说明:
游戏一共包括3个round,分数达到10分进入round2,分数达到20分进入round3;
round1、round2、round3的飞碟颜色分别为:黄色、绿色、红色;玩家摧毁(点中)它们的得分是:1、2、3;
round越高,飞碟的发射频率越高,出现的高度(范围内的随机值)越来越低,以增加反应的难度;
玩家一共有5滴血,每当一个飞碟被鼠标miss了掉入屏幕下方,玩家会掉一滴血,掉完了游戏结束;
游戏效果图:
下面介绍代码实现。
· DiskComponent
首先,用一个组件来表示飞碟的属性——分数和位置。之所以不像以往的作业“牧师与魔鬼”那样,单独写一个飞碟类,并添加属性和飞行动作,是因为我们要实现带缓存的工厂模式管理不同飞碟的生产与回收,所以最好创建与动作分开来管理。
public class DiskComponent : MonoBehaviour
{
public Vector3 direction;
public int score = 1;
}
· FlyActionManager
管理飞碟的飞行动作。当场记FirstManager需要飞碟飞行的时候,调用动作管理类的方法,让飞碟飞行。
public class FlyActionManager : SSActionManager
{
public DiskFlyAction 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 = DiskFlyAction.GetSSAction(disk.GetComponent<DiskComponent>().direction, angle, power);
this.RunAction(disk, fly, this);
}
}
· DiskFlyAction
实现飞碟的飞行动作。给飞碟一个力和一个垂直方向的加速度,再增加一个发射的角度,具体实现飞碟的飞行。当飞碟飞出了摄像机的范围(在我的游戏场景中是y坐标小于-10),则摧毁该对象,以便实现回收。
public class DiskFlyAction : SSAction
{
public float gravity = -5;
private Vector3 start_vector;
private Vector3 gravity_vector = Vector3.zero;
private float time;
private Vector3 current_angle = Vector3.zero;
private DiskFlyAction() { }
public static DiskFlyAction GetSSAction(Vector3 direction, float angle, float power)
{
DiskFlyAction action = CreateInstance<DiskFlyAction>();
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()
{
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;
if (this.transform.position.y < -10)
{
this.destroy = true;
this.callback.SSActionEvent(this);
}
}
public override void Start() { }
}
· SSAction
协助实现动作对象(飞碟)的管理,包含动作对象的状态、transform组件和消息通知对象。
public class SSAction : ScriptableObject
{
public bool enable = true;
public bool destroy = false;
public GameObject gameobject; //动作对象
public Transform transform; //动作对象的transform
public ISSActionCallback callback; //消息通知者
protected SSAction() { }
//重载以使用
public virtual void Start()
{
throw new System.NotImplementedException();
}
public virtual void Update()
{
throw new System.NotImplementedException();
}
}
· Interface
接口类,使项目的逻辑结构更清晰。
public interface ISceneController
{
void LoadResources();
}
public interface IUserAction
{
void Hit(Vector3 pos);
int GetScore();
void GameOver();
void ReStart();
void BeginGame();
}
public enum SSActionEventType : int { Started, Competeted }
public interface ISSActionCallback
{
void SSActionEvent(SSAction source, SSActionEventType events = SSActionEventType.Competeted,
int intParam = 0, string strParam = null, Object objectParam = null);
}
· DiskFactory
飞碟工厂类,根据回合的不同实现随机发送不同的飞碟。维护两个list,一个used表示正在被使用的飞碟列表;一个free表示空闲的飞碟列表。在需要发送飞碟的时候,DiskFactory先检查free列表中有没有飞碟,如果有,取出该飞碟并发送;如果free列表中没有飞碟,则实例化一个具有DiskComponent组件的飞碟。used列表中的飞碟被摧毁或者飞出了摄像机范围,则将该对象移出used列表,加入free列表。这样,就实现了对飞碟生产和回收的管理。
public class DiskFactory : MonoBehaviour
{
public GameObject disk_prefab = null; //飞碟预制
private static DiskFactory _instance;
private List<DiskComponent> used = new List<DiskComponent>();
private List<DiskComponent> free = new List<DiskComponent>();
public static DiskFactory getInstance()
{
if (_instance == null)
{
_instance = new DiskFactory();
}
return _instance;
}
public GameObject GetDisk(int round)
{
float start_y = 7;
string tag;
disk_prefab = null;
if (round == 1)
{
tag = "disk1";
}
else if (round == 2)
{
tag = "disk2";
}
else
{
tag = "disk3";
}
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, 10), Quaternion.identity);
}
else if (tag == "disk2")
{
disk_prefab = Instantiate(Resources.Load<GameObject>("Prefabs/disk2"), new Vector3(0, start_y, 10), Quaternion.identity);
}
else
{
disk_prefab = Instantiate(Resources.Load<GameObject>("Prefabs/disk3"), new Vector3(0, start_y, 10), Quaternion.identity);
}
}
used.Add(disk_prefab.GetComponent<DiskComponent>());
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
主要的场景控制器。主要完成游戏场景的控制和玩家点击事件,以及游戏状态的管理。
游戏开始时,加载资源并维护一个计时器,每隔一定时间发送一个飞碟。发送飞碟的方式是发送坐标信息给FlyActionManager类,调用Fly函数;如果游戏进入下一轮,则发送的时间间隔缩短;游戏是否进入下一轮是由分数控制的。玩家点击了飞碟或飞碟飞出了范围,则回收飞碟。
游戏状态有开始、进行、结束三种,较为简单。初始状态,玩家点击“开始”按钮开始游戏,飞碟开始发送;当玩家的血量降为0,则游戏结束,显示分数和“重新开始”按钮。
public class FirstController : MonoBehaviour, ISceneController, IUserAction
{
public FlyActionManager action_manager;
public DiskFactory disk_factory;
public UserGUI user_gui;
private Queue<GameObject> disk_queue = new Queue<GameObject>(); //飞碟队列
private List<GameObject> disk_notshot = new List<GameObject>(); //没有被打中的飞碟队列
private int round = 1;
private float speed = 2f; //发射时间间隔
private bool playing_game = false;
private bool game_over = false;
private bool game_start = false;
private int score_round2 = 10;
private int score_round3 = 20;
void Start()
{
SSDirector director = SSDirector.GetInstance();
director.CurrentScenceController = this;
disk_factory = Singleton<DiskFactory>.Instance;
action_manager = gameObject.AddComponent<FlyActionManager>() as FlyActionManager;
user_gui = gameObject.AddComponent<UserGUI>() as UserGUI;
}
void Update()
{
if (game_start)
{
if (game_over)
{
CancelInvoke("LoadResources");
}
if (!playing_game)
{
InvokeRepeating("LoadResources", 1f, speed);
playing_game = true;
}
SendDisk();
if (user_gui.score >= score_round2 && round == 1)
{
round = 2;
speed = speed - 0.6f;
CancelInvoke("LoadResources");
playing_game = false;
}
else if (user_gui.score >= score_round3 && round == 2)
{
round = 3;
speed = speed - 0.5f;
CancelInvoke("LoadResources");
playing_game = false;
}
}
}
public void LoadResources()
{
disk_queue.Enqueue(disk_factory.GetDisk(round));
}
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 = 6;
float ran_x = Random.Range(-1f, 1f) < 0 ? -1 : 1;
if (round == 2)
{
disk.GetComponent<DiskComponent>().score = 2;
float diff = Random.Range(1, 5);
ran_y -= diff;
}
else if(round == 3)
{
disk.GetComponent<DiskComponent>().score = 3;
float diff = Random.Range(6, 9);
ran_y -= 6;
}
disk.GetComponent<DiskComponent>().direction = new Vector3(ran_x, ran_y, 10);
Vector3 position = new Vector3(-disk.GetComponent<DiskComponent>().direction.x * position_x, ran_y, 15);
disk.transform.position = position;
float power = Random.Range(10f, 15f);
float angle = Random.Range(0, 0);
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]);
user_gui.ReduceBlood();
}
}
}
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<DiskComponent>() != 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);
user_gui.Record(hit.collider.gameObject);
//回收飞碟
StartCoroutine(WaitingParticle(0.08f, hit, disk_factory, hit.collider.gameObject));
break;
}
}
}
public int GetScore()
{
return user_gui.score;
}
public void ReStart()
{
game_over = false;
playing_game = false;
user_gui.score = 0;
round = 1;
speed = 2f;
}
public void GameOver()
{
game_over = true;
}
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);
}
public void BeginGame()
{
game_start = true;
}
}
· UserGUI
最后是用户交互界面,主要显示分数、玩家血量和控制按钮等。由于用户的血量和分数控制都较为简单,并且相对独立,我就没有把它们单独写成一个类,而是直接在本类中进行控制。这样做从某种意义上来说是不符合MVC模式的,因为没有实现控制器和视图的分离。但是,考虑到控制器只有短短几行代码,仅仅将一个函数封装成类,难免显得过度封装、代码不简洁。所以,考虑之后我还是没有将它们分离出来。
public class UserGUI : MonoBehaviour
{
private IUserAction action;
public int life = 5;
public int score = 0;
GUIStyle bold_style = new GUIStyle();
GUIStyle score_style = new GUIStyle();
GUIStyle text_style = new GUIStyle();
GUIStyle over_style = new GUIStyle();
private int high_score = 0;
private bool game_start = false;
void Start()
{
action = SSDirector.GetInstance().CurrentScenceController as IUserAction;
}
public void Record(GameObject disk)
{
int temp = disk.GetComponent<DiskComponent>().score;
score = temp + score;
}
void OnGUI()
{
bold_style.normal.textColor = new Color(1, 0, 0);
bold_style.fontSize = 20;
text_style.normal.textColor = new Color(1, 1, 1);
text_style.fontSize = 20;
score_style.normal.textColor = new Color(0, 1, 0);
score_style.fontSize = 20;
over_style.normal.textColor = new Color(1, 0, 0);
over_style.fontSize = 25;
if (game_start)
{
//射击
if (Input.GetButtonDown("Fire1"))
{
Vector3 pos = Input.mousePosition;
action.Hit(pos);
}
GUI.Label(new Rect(450, 400, 200, 50), "分数:", text_style);
GUI.Label(new Rect(500, 400, 200, 50), action.GetScore().ToString(), score_style);
GUI.Label(new Rect(Screen.width - 500, 400, 50, 50), "生命:", text_style);
for (int i = 0; i < life; i++)
{
GUI.Label(new Rect(Screen.width - 450 + 10 * i, 400, 50, 50), "0", bold_style);
}
//游戏结束
if (life == 0)
{
high_score = high_score > action.GetScore() ? high_score : action.GetScore();
GUI.Label(new Rect(Screen.width / 2 - 20, Screen.width / 2 - 250, 100, 100), "游戏结束", over_style);
GUI.Label(new Rect(Screen.width / 2 - 10, Screen.width / 2 - 200, 50, 50), "最高分:", text_style);
GUI.Label(new Rect(Screen.width / 2 + 50, Screen.width / 2 - 200, 50, 50), high_score.ToString(), text_style);
if (GUI.Button(new Rect(Screen.width / 2 - 20, Screen.width / 2 - 320, 100, 50), "重新开始"))
{
life = 6;
action.ReStart();
return;
}
action.GameOver();
}
}
else
{
if (GUI.Button(new Rect(Screen.width / 2 - 20, Screen.width / 2 - 300, 100, 50), "开始"))
{
game_start = true;
action.BeginGame();
}
}
}
public void ReduceBlood()
{
if (life > 0)
life--;
}
}
做完这些,再添加一个Skybox来美化一下游戏场景,就大功告成啦。
GitHub传送门:Disk
鸣谢师兄提供的博客供我参考!
2、编写一个简单的自定义 Component (选做)
用自定义组件定义几种飞碟,做成预制。参考官方脚本手册 https://docs.unity3d.com/ScriptReference/Editor.html
实现自定义组件,编辑并赋予飞碟一些属性。
写好上文描述的DiskComponent后,我们需要将该脚本挂载在飞碟预设上。之后,新建一个DiskEditor类,先序列化属性,然后可以修改属性并应用。
自定义组件可以大大提高编程效率,使我们避免重复劳动。
[CustomEditor(typeof(DiskData))]
[CanEditMultipleObjects]
public class DiskEditor : Editor
{
//序列化飞碟的属性,如DiskComponent中的score
//要丰富自定义组件的内容,可以给飞碟增加一些额外的属性,比如大小、颜色等等
SerializedProperty score;
void OnEnable()
{
score = serializedObject.FindProperty("score");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.IntSlider(score, 0, 5, new GUIContent("score"));
//应用修改
serializedObject.ApplyModifiedProperties();
}
}