Unity3D游戏编程-鼠标打飞碟
一、作业要求
1、编写一个简单的鼠标打飞碟(Hit UFO)游戏。 游戏内容要求:
- 游戏有 n 个 round,每个 round 都包括10 次 trial;
- 每个 trial 的飞碟的色彩、大小、发射位置、速度、角度、同时出现的个数都可能不同。它们由该 round 的 ruler 控制;
- 每个 trial 的飞碟有随机性,总体难度随 round 上升;
- 鼠标点中得分,得分规则按色彩、大小、速度不同计算,规则可自由设定。
游戏的要求:
- 使用带缓存的工厂模式管理不同飞碟的生产与回收,该工厂必须是场景单实例的!具体实现见参考资源 Singleton 模板类;
- 近可能使用前面 MVC 结构实现人机交互与游戏模型分离;
- 按 adapter模式 设计图修改飞碟游戏,使它同时支持物理运动与运动学(变换)运动。
二、项目配置
Windows 10 Unity 2020.3.17f1c1
三、项目演示
视频演示
点击此处可以前往 可开启字幕说明
项目下载
下载Assets文件夹 点击此处可以前往gitee
文字说明
- 创建unity专案后,将保存的文件夹中的Assets替换成在上面项目下载的Assets文件夹
- 打开专案,然后点选Assets中的play加载场景
- 运行即可开始游戏
- 点击左方的Start with Physics以物理学的方式开启游戏,点击右方Start with Kinematics以运动学的方式开启
- 游戏有三轮,第一轮仅有白色飞碟(1分),第二轮加入黄色飞碟(2分),第三轮加入蓝色飞碟(4分)
- 当总分超过30分,游戏胜利
- 游戏进行时可以随时暂停
项目截图
四、前置内容
MVC模式
在之前牧师与恶魔的游戏设计中,我们接触到了MVC结构,此处再复习一下:
- SSDirector负责控制场景,把握全局
- FirstController负责控制布景(实现SceneController的函数),管理动作执行(实现UserAction的函数)
- SceneController负责设置布景,其本质是一个接口
- UserAction负责游戏操作,本质是一个接口
- GUI负责游戏的画面交互
更详细的说明可以看之前的作业: 点击此处查看
动作管理器
并且在本次实现中,也有用到动作管理器: 关于动作管理器的相关作业可以点击此处查看
适配器
适配器将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类能一起工作。 适配器的优点: 将目标类和适配者类解耦,通过引入一个适配器类来重用现有的适配者类,而无须修改原有代码。适配器还增加了类的透明性和复用性,将具体的实现封装在适配者类中,对于客户端类来说是透明的,而且提高了适配者的复用性。
五、实现过程和方法(算法)
运动学和物理学运动方式,主要的区别在于,物理学执行动作的时候不需要我们使用transform.Translate()。 并且通过前几次作业的结合,可以简单画出本次作业程序的结构:
Director
Director类和上次的编写一模一样,也是利用单例模式和懒汉模式。
SceneController
SceneController也是一个接口,但这一次的游戏并不需要预先加载角色(上次要加载牧师、恶魔、船之类的对象,但这次没有),所以loadResources在FirstController的实现为空。
public interface ISceneControl
{
void loadResources();
}
UserAction
UserAction是门面模式,与用户交互相关的,与玩家动作有关:
public enum GameState { ROUND_START, ROUND_FINISH, RUNNING, PAUSE, START, FUNISH }
public interface IUserAction
{
GameState getGameState();
void setGameState(GameState gameState);
int getScore();
void hit(Vector3 pos);
bool getActionMode();
void setActionMode(bool mode);
}
FristSceneControl
FristSceneControl就要实现上面(SceneController、UserAction)两个类的函数。
- 先看一些初始设置
public ActionManagerAdapter actionManager { set; get; }
public ScoreRecorder scoreRecorder { set; get; }
public Queue<GameObject> diskQueue = new Queue<GameObject>();
private int diskNumber = 0;
private int currentRound = -1;
private float time = 0;
private GameState gameState = GameState.START;
UserGUI userGUI;
private bool isPhysical = false;
· 这次作业需要实现让运动学和动力学两种模式的共存,因此就需要利用适配器。对于FirstSceneActionManager,有两个类,一个是CCActionManager,负责运动学的行为;另一个是ModifiedActionManager,负责物理学的行为。 · ScoreRecorder类是专门负责记录成绩的
- 对于项目的初始化(Awake):
void Awake()
{
Director director = Director.getInstance();
director.current = this;
diskNumber = 10;
this.gameObject.AddComponent<ScoreRecorder>();
this.gameObject.AddComponent<DiskFactory>();
scoreRecorder = Singleton<ScoreRecorder>.Instance;
userGUI = gameObject.AddComponent <UserGUI>() as UserGUI;
director.current.loadResources();
}
· scoreRecorder = Singleton<ScoreRecorder>.Instance; 使得我们获取ScoreRecorder的单例对象。Singleton类的写法就是老师给出的模板类。
- SceneController中的loadResources实现,本次为空。
public void loadResources()
{
}
- UserAction的getGameState(),目的是将变量gameState进行return
- UserAction的setGameState(GameState gameStateIn),设置变量gameStat,将gameStat设置为传入的参数即可
- UserAction的getScore(),返回变量scoreRecorder.score
- UserAction的hit(Vector3 pos)主要利用光标拾取多个物体的程序:
public void hit(Vector3 pos)
{
RaycastHit[] hits = Physics.RaycastAll(Camera.main.ScreenPointToRay(pos));
for (int i = 0; i < hits.Length; i++)
{
RaycastHit hit = hits[i];
if (hit.collider.gameObject.GetComponent<DiskData>() != null)
{
scoreRecorder.record(hit.collider.gameObject);
hit.collider.gameObject.transform.position = new Vector3(0, -5, 0);
}
}
}
· RaycastHit是用于检测碰撞的 · Camera.main.ScreenPointToRay(pos)表示一个从摄像机原点出发指向目标位置pos的射线,Physics.RaycastAll得到碰撞结果存储在RaycastHit · 如果击中的对象是有效飞碟,则计算分数以及把这个飞碟弄走
- UserAction的getActionMode(),返回变量isPhysical即可。
- UserAction的setActionMode(bool mode),设置变量isPhysical为传入参数。
- 之后是FirstControl对游戏场景的更新控制:
private void Update()
{
if (actionManager == null)
{
return;
}
if (actionManager.getDiskNumber() == 0 && gameState == GameState.RUNNING)
{
gameState = GameState.ROUND_FINISH;
if (currentRound == 2)
{
gameState = GameState.FUNISH;
return;
}
}
if (actionManager.getDiskNumber() == 0 && gameState == GameState.ROUND_START)
{
currentRound++;
nextRound();
actionManager.setDiskNumber(10);
gameState = GameState.RUNNING;
}
if (time > 1 && gameState != GameState.PAUSE)
{
throwDisk();
time = 0;
}
else
{
time += Time.deltaTime;
}
}
而其中nextRound():
private void nextRound()
{
DiskFactory diskFactory = Singleton<DiskFactory>.Instance;
for (int i = 0; i < diskNumber; i++)
{
diskQueue.Enqueue(diskFactory.getDisk(currentRound,isPhysical));
}
actionManager.startThrow(diskQueue);
}
throwDisk():
void throwDisk()
{
if (diskQueue.Count != 0)
{
GameObject disk = diskQueue.Dequeue();
Vector3 pos = new Vector3(-disk.GetComponent<DiskData>().getDirection().x * 10, Random.Range(0f, 4f), 0);
disk.transform.position = pos;
disk.SetActive(true);
}
}
nextRound中的startThrow是让飞碟队列的每个飞碟都用函数runAction执行了动作,但是实际上要等throwDisk中SetActive之后才会实际运动。
ActionManagerAdapter
因为FristSceneContoller要调用ActionManager的函数,并且这次由于有两种模式的存在,所以FristSceneContoller会通过ActionManagerAdapter(适配器)来调用相关模式下的函数。 适配器本质上就是一个接口,而继承适配器的类就要实现这些接口,也通过适配器进行了统一。
public interface ActionManagerAdapter
{
void setDiskNumber(int dn);
int getDiskNumber();
SSAction getSSAction();
void freeSSAction(SSAction action);
void SSActionEvent(SSAction source, SSActionEventType events = SSActionEventType.Completed, int intPram = 0, string strParm = null, Object objParm = null);
void startThrow(Queue<GameObject> diskQueue);
}
然后就是运动学CCActionManager和物理学ModifiedActionManager各自对这个函数进行实现。
CCActionManager与ModifiedActionManager
这两个类在这次的作业中其实代码差不多,主要区别是Update中一个要使用刚体一个不用。 对于CCActionManager:
protected void Start()
{
sceneControl = (FirstSceneControl)Director.getInstance().current;
sceneControl.actionManager = this;
flys.Add(CCFlyAction.getCCFlyAction());
base.flag = true;
}
private new void Update()
{
if (sceneControl.getGameState() == GameState.RUNNING)
base.Update();
}
而ModifiedActionManager:
protected void Start()
{
sceneControl = (FirstSceneControl)Director.getInstance().current;
sceneControl.actionManager = this;
flys.Add(CCFlyAction.getCCFlyAction());
base.flag = false;
}
private new void Update()
{
if (sceneControl.getGameState() == GameState.RUNNING)
{
base.Update();
base.startRigidbodyAction();
}
else
{
base.stopRigidbodyAction();
}
}
SSActionManager
ActionManager跟上次作业的基本相似,多了要判断哪一种运动方式用不同的update:
protected void Update()
{
foreach (KeyValuePair<int, SSAction> i in actions)
{
SSAction value = i.Value;
if (value.destroy)
{
waitingDelete.Add(value.GetInstanceID());
}
else if (value.enable && flag)
{
value.Update();
}
else if(value.enable && !flag)
{
value.FixedUpdate();
}
}
}
还有启动和停止刚体的函数:
public void stopRigidbodyAction()
{
foreach (SSAction action in actions.Values)
{
action.rigidbodyStopAction();
}
}
public void startRigidbodyAction()
{
foreach (SSAction action in actions.Values)
{
action.rigidbodyStartAction();
}
}
SSAction
SSAction也实际上是一个接口,在本次作业的程序中,类CCFlyAction就负责实现这个最底部被继承的动作类。
CCFlyAction
CCFlyAction是负责实现飞碟飞行动作的类,它负责设置了大部分的变量参数。
float acceleration;
float horizontalSpeed;
Vector3 direction;
float time;
bool flag=false;
Vector3 temp;
Rigidbody rigidbody;
public override void Start()
{
enable = true;
acceleration = 9.8f;
time = 0;
horizontalSpeed = gameObject.GetComponent<DiskData>().getSpeed();
direction = gameObject.GetComponent<DiskData>().getDirection();
rigidbody = gameObject.GetComponent<Rigidbody>();
if (rigidbody)
{
rigidbody.velocity = horizontalSpeed * direction;
temp = rigidbody.velocity;
}
}
然后是行为的实际执行方式:
public override void Update()
{
if (gameObject.activeSelf)
{
time += Time.deltaTime;
transform.Translate(Vector3.down * acceleration * time * Time.deltaTime);
transform.Translate(direction * horizontalSpeed * Time.deltaTime);
if (this.transform.position.y < -4)
{
this.destroy = true;
this.enable = false;
this.callback.SSActionEvent(this);
}
}
}
public override void FixedUpdate()
{
if (gameObject.activeSelf)
{
if (this.transform.position.y < -4)
{
this.destroy = true;
this.enable = false;
this.callback.SSActionEvent(this);
}
}
}
DiskFactory
DiskFactory给我感觉有点像线程池,预先创建了一些飞碟,当需要用的时候才会取出。
对于一个飞碟,有以下的一些参数:
private Vector3 size;
private Color color;
private float speed;
private Vector3 direction;
而工厂应该要有一个空闲队列和使用队列,用来记录哪些飞碟目前还没被使用,哪些已经被Active:
public List<DiskData> used = new List<DiskData>();
public List<DiskData> free = new List<DiskData>();
工厂的Awake()函数是加载飞碟对象的预建,并且设置它非Active:
private void Awake()
{
diskPrefab = GameObject.Instantiate<GameObject>(Resources.Load<GameObject>("Prefabs/Disk"), Vector3.zero, Quaternion.identity);
diskPrefab.SetActive(false);
}
此时的飞碟的参数都并没有被设置,也就是大小颜色那些都没有设定。
之后是从工厂种取出飞碟的getDisk()函数,传入的参数是当前第几轮(用以判断可以出什么颜色的飞碟)、是否使用刚体。getDisk()的工作主要有:
- 判断空闲队列还有没有飞碟,有的话直接取出空闲队列中的一个飞碟,并且把它从空闲队列移除。
- 如果空闲队列没有飞碟,则重新创建一个飞碟对象直接使用。
- 判断是否利用刚体,如果是的话,给刚刚所取出的飞碟套上刚体属性。
- 根据当前所进行到的回合数(约往后给出的飞碟会变小并且速度更快),设定飞碟的大小、颜色、速度、位置。
- return这个飞碟对象
当一个飞碟使用完毕进行回收时,可以利用freeDisk(GameObject disk)函数。
- 要把它设置成非Active
- 把这个飞碟放到空闲队列,并且从使用队列中移除。
ScoreRecorder
ScoreRecorder就是拿来记录不同颜色的飞碟对应的分数,以及当玩家击中飞碟时计算总得分。 因此它的初始化是设置总分为0、设置各种颜色飞碟对应多少分:
void Start()
{
score = 0;
scoreTable.Add(Color.white, 1);
scoreTable.Add(Color.gray, 2);
scoreTable.Add(Color.black, 4);
}
当击中某个飞碟时,就可以调用record(GameObject disk)函数,把飞碟对象传入,让这个函数去计算得分:
public void record(GameObject disk)
{
score += scoreTable[disk.GetComponent<DiskData>().getColor()];
}
六、参考资料
1.Unity3d-learning 物理碰撞打飞碟小游戏 2.Unity3D 入门小技巧——鼠标拾取并移动物体(示例代码)
|