游戏对象与图形基础
3D游戏设计第四次作业
前言
这是中山大学2020年3D游戏设计的第四次作业,如有错误,请指正,感谢您的阅读。
基本操作演练【建议做】
- 下载Fantasy Skybox FREE,构建自己的游戏场景
在Asset中搜索Fantasy Skybox FREE,导入后直接使用
- 在 Camera 对象中添加部件 Rendering -> Skybox
- 将天空盒拖放入 Skybox
即可导入。这个过程将在编程训练中具体展现。
- 写一个简单的总结,总结游戏对象的使用
首先我们应该知道游戏对象是所有其他组件的容器。游戏对象可以容纳很多组件,比如Transform,我们可以改变Transform的各个参数的值来改变游戏对象的位置。改变游戏对象的状态其实就是对游戏对象身上的组件进行更改。获得一个游戏对象的一个组件我们可以使用GetComponent方法,然后对组件的值进行修改。用AddComponent方法,在游戏对象上添加一个组件。
游戏对象也有自己的属性,可以利用这些属性比如可以使用Find方法将Name的游戏对象。
最后可以实例化游戏对象用Instantiate方法让它出现在场景中,也可以使用Destroy方法让游戏对象销毁消失在场景中。
编程实践
牧师与魔鬼 动作分离版
设计一个裁判类,当游戏达到结束条件时,通知场景控制器游戏结束。
动作分离设计思路
- 通过门面模式(控制器模式)输出组合好的几个动作,共原来程序调用。
- 好处:动作如何组合变成动作模块内部的事务
- 这个门面就是 CCActionManager
- 通过组合模式实现动作组合,按组合模式设计方法
- 必须有一个抽象事物表示该类事物的共性,例如 SSAction,表示动作,不管是基本动作或是组合后动作
- 基本动作,用户设计的基本动作类。 例如:CCMoveToAction
- 组合动作,由(基本或组合)动作组合的类。例如:CCSequenceAction
- 接口回调(函数回调)实现管理者与被管理者解耦
- 如组合对象实现一个事件抽象接口(ISSCallback),作为监听器(listener)监听子动作的事件
- 被组合对象使用监听器传递消息给管理者。至于管理者如何处理就是实现这个监听器的人说了算了
- 例如:每个学生做完作业通过邮箱发消息给学委,学委是谁,如何处理,学生就不用操心了
- 通过模板方法,让使用者减少对动作管理过程细节的要求
- SSActionManager 作为 CCActionManager 基类
最终,程序员可以方便的定义动作并实现动作的自由组合,做到:
- 程序更能适应需求变化
- 对象更容易被复用
- 程序更易于维护
动作管理器的设计图
相对于上一版的更新
- 将MVC的设计改为了动作分离的设计
- 增加了天空盒设计
- 增加了restart设计
- 增加了裁判类进行判断
- 略微修改了UI,使其更加合理
代码分析
本次代码分析中有部分引自老师上课的讲义,特此声明。
核心代码(老师提供)
动作基类——SSAction
设计要点:
ScriptableObject
是不需要绑定GameObject
对象的可编程基类。这些对象受 Unity 引擎场景管理protected
防止用户自己new
抽象的对象- 使用
virtual
申明虚方法,通过重写实现多态。这样继承者就明确使用Start
和Update
编程游戏对象行为 - 利用接口
ISSACtionCallback
实现消息通知,避免与动作管理者直接依赖
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SSAction : ScriptableObject
{
public bool enable = false;
public bool destroy = false;
public GameObject gameobject { get; set; }
public Transform transform { get; set; }
public ISSActionCallback callback { get; set; }
protected SSAction() { }
public virtual void Start()
{
throw new System.NotImplementedException();
}
public virtual void Update()
{
throw new System.NotImplementedException();
}
}
简单动作实现——CCMoveToAction
这个模块实现具体动作,将一个物体移动到目标位置,并通知任务完成。
设计要点:
- 让 Unity 创建动作类,确保内存正确回收。别指望开发者是 c 语言高手。
- 多态。C++ 语言必申明重写,Java则默认重写
- 似曾相识的运动代码。动作完成,并发出事件通知,期望管理程序自动回收运行对象。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CCMoveToAction : SSAction
{
public Vector3 target;
public float speed;
public static CCMoveToAction GetSSAction(Vector3 target, float speed)
{
CCMoveToAction action = ScriptableObject.CreateInstance<CCMoveToAction>();
action.target = target;
action.speed = speed;
Debug.Log(speed.ToString());
return action;
}
public override void Update()
{
this.transform.position = Vector3.MoveTowards(this.transform.position, target, speed * Time.deltaTime);
if (this.transform.position == target)
{
this.enable = false;
this.callback.SSActionEvent(this);
}
}
}
动作事件接口定义——ISSActionCallback
该部分定义接口作为接收通知对象的抽象类型.
设计要点:
- 事件类型定义,使用了枚举变量
- 定义了事件处理接口,所有事件管理者都必须实现这个接口,来实现事件调度。所以,组合事件需要实现它,事件管理器也必须实现它。
- 这里展示了语言函数默认参数的写法。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
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);
}
动作管理基类——SSActionManager
这是动作对象管理器的基类,实现了所有动作的基本管理。
设计要点:
- 创建
MonoBehaiviour
管理一个动作集合,动作做完自动回收动作。
- 有一个动作字典。
- 待加入、删除动作列表。
update
演示了由添加、删除等复杂集合对象的使用。- 提供了添加新动作的方法
RunAction
。该方法把游戏对象与动作绑定,并绑定该动作事件的消息接收者。 - 执行该动作的
Start
方法
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class SSActionManager : MonoBehaviour
{
private Dictionary<int, SSAction> actions = new Dictionary<int, SSAction>();
private List<SSAction> waitingAdd = new List<SSAction>();
private List<int> waitingDelete = new List<int>();
// Use this for initialization
protected void Start()
{
}
// Update is called once per frame
protected void Update()
{
foreach (SSAction ac in waitingAdd) actions[ac.GetInstanceID()] = ac;
waitingAdd.Clear();
foreach (KeyValuePair<int, SSAction> kv in actions)
{
SSAction ac = kv.Value;
if (ac.destroy)
{
waitingDelete.Add(ac.GetInstanceID());
}
else if (ac.enable)
{
ac.Update();
}
}
foreach (int key in waitingDelete)
{
SSAction ac = actions[key];
actions.Remove(key);
DestroyObject(ac);
}
}
public void RunAction(GameObject gameobject, SSAction action, ISSActionCallback manager)
{
action.gameobject = gameobject;
action.transform = gameobject.transform;
action.callback = manager;
waitingAdd.Add(action);
action.Start();
}
}
使用动作组合
CCActionManager
部署:
- 部署船向左移和向右移的动作和每个人物对应的上下船的动作
- 启动所有部署的动作
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CCActionManager : SSActionManager, ISSActionCallback
{
public FirstSceneControl sceneController;
public CCMoveToAction moveToLeft, moveToRight;
public Dictionary<int, CCOn_OffAction> on_off = new Dictionary<int, CCOn_OffAction>();
public void SSActionEvent(SSAction source,
SSActionEventType events = SSActionEventType.Competeted,
int intParam = 0,
string strParam = null,
Object objectParam = null)
{
}
protected new void Start()
{
float speed = 10f;
sceneController = (FirstSceneControl)Director.getInstance().currentSceneControl;
sceneController.actionManager = this;
moveToLeft = CCMoveToAction.GetSSAction(sceneController.Boat_Left, speed);
moveToRight = CCMoveToAction.GetSSAction(sceneController.Boat_Right, speed);
foreach (KeyValuePair<int, GameObject> obj in sceneController.On_Shore_r)
{
on_off[obj.Key] = CCOn_OffAction.GetSSAction();
}
this.RunAction(sceneController.boat, moveToLeft, this);
this.RunAction(sceneController.boat, moveToRight, this);
foreach (KeyValuePair<int, GameObject> obj in sceneController.On_Shore_r)
{
this.RunAction(obj.Value, on_off[obj.Key], this);
}
}
}
CCOn_OffAction
- 实现人物上下船
- 原来的该部分位于FirstControlScene中
- 在原来的基础上增加了动作管理器的相关操作,但是基本操作还是完全相同的
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class CCOn_OffAction : SSAction
{
private FirstSceneControl firstSceneControl;
enum Pos { ON_BOAT, ON_SHORE }
Pos find_Pos(int id)
{
if (id >= 6) return Pos.ON_BOAT;
return Pos.ON_SHORE;
}
public static CCOn_OffAction GetSSAction()
{
CCOn_OffAction action = ScriptableObject.CreateInstance<CCOn_OffAction>();
return action;
}
public override void Start()
{
firstSceneControl = (FirstSceneControl)Director.getInstance().currentSceneControl;
}
public override void Update()
{
if (firstSceneControl.game_state == GameState.NOT_ENDED)
{
if (firstSceneControl.b_state == Judge.BoatState.MOVING) return;
int id = Convert.ToInt32(gameobject.name);
if (firstSceneControl.b_state == Judge.BoatState.STOPRIGHT)
{
if (firstSceneControl.On_Shore_r.ContainsKey(id))
{
if (find_Pos(id) == Pos.ON_SHORE && firstSceneControl.boat_capicity != 0)
{
firstSceneControl.On_Boat.Add(id + 6, firstSceneControl.On_Shore_r[id]);
firstSceneControl.On_Shore_r.Remove(id);
gameobject.name = (id + 6).ToString();
gameobject.transform.parent = firstSceneControl.boat.transform;
firstSceneControl.boat_capicity--;
}
}
if (find_Pos(id) == Pos.ON_BOAT)
{
firstSceneControl.On_Shore_r.Add(id - 6, firstSceneControl.On_Boat[id]);
firstSceneControl.On_Boat.Remove(id);
gameobject.name = (id - 6).ToString();
gameobject.transform.parent = null;
firstSceneControl.boat_capicity++;
}
}
if (firstSceneControl.b_state == Judge.BoatState.STOPLEFT)
{
if (find_Pos(id) == Pos.ON_SHORE && firstSceneControl.boat_capicity != 0)
{
if (firstSceneControl.On_Shore_l.ContainsKey(id))
{
firstSceneControl.On_Boat.Add(id + 6, firstSceneControl.On_Shore_l[id]);
firstSceneControl.On_Shore_l.Remove(id);
gameobject.name = (id + 6).ToString();
gameobject.transform.parent = firstSceneControl.boat.transform;
firstSceneControl.boat_capicity--;
}
}
if (find_Pos(id) == Pos.ON_BOAT)
{
firstSceneControl.On_Shore_l.Add(id - 6, firstSceneControl.On_Boat[id]);
firstSceneControl.On_Boat.Remove(id);
gameobject.name = (id - 6).ToString();
gameobject.transform.parent = null;
firstSceneControl.boat_capicity++;
}
}
int new_id = Convert.ToInt32(gameobject.name);
if (id != new_id)
{
this.enable = false;
this.callback.SSActionEvent(this);
}
}
}
}
控制器
控制器与上次的设计差不多,这里的阐述跟上次也基本相同。
Director
Director是最高层的控制器,运行游戏时始终只有一个实例,它控制场景的加载、切换,游戏进程状态等等,可以说,是控制器的控制器。
我们要想让游戏顺利的运行,最重要的就是控制住只有一个Director实例,因为只有这样,我们才可以保证所有的脚本中获得的都是同一个Director对象,这样,也就能通过Director进行类之间的通信。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Director : System.Object
{
public MySceneControl currentSceneControl { get; set; }
private static Director director;
public static Director getInstance()
{
if (director == null)
{
director = new Director();
}
return director;
}
}
SceneController——场景控制器接口
SceneController接口主要是Director控制场景控制器的渠道,但是在这里,只是一个接口,后续会用一个类来继承它并给出具体方法。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public interface MySceneControl
{
void GenGameObjects();
}
IUserAction——用户动作控制接口
IUserAction的作用是给用户GUI提供控制接口,我们需要后台判断游戏的状态,需要获取剩余时间,同时还需要移动小船。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public enum GameState { WIN, FAILED, NOT_ENDED,TIME_UP }
public interface IUserAction
{
void MoveBoat();
GameState getGameState();
float Gettime();
void Restart();
}
On_Off——角色控制器
On_off是角色移动的控制器,主要包括上下船,上下岸的过程。这个控制器主要接受的是点击的信号,只有点击角色时才会触发。
我们这里利用刚刚定义的动作组合实现。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[DisallowMultipleComponent]
public class On_Off : MonoBehaviour
{
private FirstSceneControl firstSceneControl;
void Start()
{
firstSceneControl = (FirstSceneControl)Director.getInstance().currentSceneControl;
}
private void OnMouseDown()
{
int id = Convert.ToInt32(this.name);
Debug.Log(id);
if (firstSceneControl.b_state != Judge.BoatState.MOVING)
{
if (firstSceneControl.actionManager.on_off.ContainsKey(id))
{
Debug.Log(firstSceneControl.actionManager.on_off[id].enable);
firstSceneControl.actionManager.on_off[id].enable = true;
}
if (firstSceneControl.actionManager.on_off.ContainsKey(id - 6)) firstSceneControl.actionManager.on_off[id - 6].enable = true;
}
}
}
Judge——裁判类
裁判类需要传入的参数有:在船上的物体,在两岸的物体,船的状态以及时间。而这里的实现方式与上次也完全相同,我们利用check()
函数判断当前的游戏状态。
我们首先介绍构造函数,构造函数只需要将传入的值进行一一赋值即可。
public Judge(Dictionary<int, GameObject> _On_Boat, Dictionary<int, GameObject> _On_Shore_r, Dictionary<int, GameObject> _On_Shore_l, BoatState _b_state, float _timer)
{
On_Boat = _On_Boat;
On_Shore_r = _On_Shore_r;
On_Shore_l = _On_Shore_l;
b_state = _b_state;
timer = _timer;
}
下面是基本与上次一模一样的check()
函数,时间的处理有所改变。
public GameState check()
{
if (On_Shore_l.Count == 6&&timer>0f)
{
return GameState.WIN;
}
else if (b_state == BoatState.STOPLEFT && timer > 0f)
{
if (get_num(On_Boat, 1) + get_num(On_Shore_l, 1) != 0 && get_num(On_Boat, 1) + get_num(On_Shore_l, 1) < (get_num(On_Boat, -1) + get_num(On_Shore_l, -1)))
{
//牧师不为0,且牧师数小于恶魔数
return GameState.FAILED;
}
if (get_num(On_Shore_r, 1) != 0 && get_num(On_Shore_r, 1) < get_num(On_Shore_r, -1))
{
//这步是为了修正对岸的情况,以免出现卡bug情况
return GameState.FAILED;
}
}
else if (b_state == BoatState.STOPRIGHT && timer > 0f)
{
if (get_num(On_Boat, 1) + get_num(On_Shore_r, 1) != 0 && get_num(On_Boat, 1) + get_num(On_Shore_r, 1) < (get_num(On_Boat, -1) + get_num(On_Shore_r, -1)))
{
//牧师不为0,且牧师数小于恶魔数
return GameState.FAILED;
}
if (get_num(On_Shore_l, 1) != 0 && get_num(On_Shore_l, 1) < get_num(On_Shore_l, -1))
{
//这步是为了修正对岸的情况,以免出现卡bug情况
return GameState.FAILED;
}
}
else if (timer <= 0f) return GameState.TIME_UP;
else
{
return GameState.NOT_ENDED;
}
return GameState.NOT_ENDED;
}
check()
中的get_num()
函数与上次相同。
public int get_num(Dictionary<int, GameObject> dict, int ch)
{
var keys = dict.Keys;
int d_num = 0;
int p_num = 0;
foreach (int i in keys)
{
if (i < 3 || (i >= 6 && i <= 8))
{
p_num++;
}
else
{
d_num++;
}
}
return (ch == 1 ? p_num : d_num);
}
至此,完成了Judge
裁判类。
FirstSceneControl——场景控制器实例
FirstSceneControl
中部分与上次基本相同(Awake
,Gettime
,getGameState
,GenGameObjects
),这里仅介绍与上次有大幅度修改的函数。
Update
Update函数需要调用Judge类来进行状态判断,返回该状态并进一步判断船的状态,最后进行位置修正。
private void Update()
{
Judge newjudge = new Judge(On_Boat, On_Shore_r, On_Shore_l, b_state, timer);
game_state = newjudge.check();
if (timer > 0 && game_state == GameState.NOT_ENDED) timer -= Time.deltaTime * 10;
if (game_state == GameState.NOT_ENDED)//判断状态
{
if (boat.transform.position == Boat_Left)
{
b_state = Judge.BoatState.STOPLEFT;
}
else if (boat.transform.position == Boat_Right)
{
b_state = Judge.BoatState.STOPRIGHT;
}
else
{
b_state = Judge.BoatState.MOVING;
}
for (int i = 0; i < 6; i++)
{
if (On_Shore_l.ContainsKey(i)) On_Shore_l[i].transform.position = new Vector3(-12.5f - i * gab, -7.5f, 0);
if (On_Shore_r.ContainsKey(i)) On_Shore_r[i].transform.position = new Vector3(12.5f + i * gab, -7.5f, 0);//位置修正
}
int signed = 1;
for (int i = 6; i < 12; i++)
{
if (On_Boat.ContainsKey(i))//在船上的角色位置修正
{
On_Boat[i].transform.localPosition = new Vector3(signed * 0.3f, 1, 0);
signed = -signed;
}
}
}
}
MoveBoat
MoveBoat
比以前简化了很多,直接调用动作管理器进行移动即可。
public void MoveBoat()
{
if (On_Boat.Count != 0)
{
if (b_state == Judge.BoatState.STOPLEFT)
{
actionManager.moveToRight.enable = true;
}
if (b_state == Judge.BoatState.STOPRIGHT)
{
actionManager.moveToLeft.enable = true;
}
}
}
Restart
Restart
需要将所有的物体回归原位,并且重置时间。注意要将物体回归原处而不是重新新建,否则会造成物体找不到动作管理器而不工作。
public void Restart()
{
foreach (var item in On_Boat.ToList())
{
int id = Convert.ToInt32(On_Boat[item.Key].name)-6;
Debug.Log("id:" + id);
On_Shore_r.Add(id,On_Boat[id+6]);
On_Shore_r[id].name = id.ToString();
On_Boat[item.Key].transform.parent = null;
On_Boat.Remove(id + 6);
}
foreach (var item in On_Shore_l.ToList())
{
int id = Convert.ToInt32(On_Shore_l[item.Key].name);
On_Shore_r.Add(id, On_Shore_l[id]);
On_Shore_l.Remove(id);
}
boat_capicity = 2;//船上只能有两个人
b_state = Judge.BoatState.STOPLEFT;
boat.transform.position = Boat_Right;
timer = 60f;
game_state = GameState.NOT_ENDED;
}
GUI
UI与上次基本相同,只是增加了一个Restart
按钮,如下:
if (GUI.Button(new Rect(160, 350, 100, 80), "restart!") && action.getGameState() != GameState.NOT_ENDED)
{
action.Restart();
}
天空盒设置
在Asset Store中下载Fantasy Skybox FREE,下载完成后,导入到自己的项目中,在该资源包中已经有一些做好的天空盒,我们在主相机中找到Camera
属性,然后将Clear Flags
设为Skybox,然后新建属性选择SkyBox,将一个资源拖入即可。因为这里我们使用的是2D视角展示游戏,故直接使用包中自带的天空盒即可,如图所示:
成品展示
如图,时间加速了十倍来更好的进行展示。
模型使用
本游戏使用了Asset Store中的:assets,Skybox
错误与总结
本次魔鬼与牧师动作分离版的关键在于动作的分离,因此我们需要动作管理器。在动作管理器中,因为我们要管理多个动作,所以,我们要对每个动作都进行override start
的初始化工作(即使它的start是空的也要写上去!!),我就因为一个start是空所以省略不写导致debug几小时。其次,要注意unity的报错,在unity中,如果同时打开两个或者一个保持时间过长会有可能报错Asset database transaction committed twice!
等等一系列的错误,这时,需要重启一下unity即可解决。
最后来说一下游戏,我认为这次最难的应该是类与类之间的交互关系,因为把动作分离,所以我们需要及时的调整变量以控制动作的及时触发。同时,这次增加了Restart函数,一开始我是将所有对象销毁然后重新生成,会发现这样并不能将动作管理加载到新的对象上,因此,采用将所有对象回归原位的方法进行重置,发现这样会有很好的效果。
总的来说,本次实验的重点在于如何使用动作管理将所有动作分离出去,而这一方法也让我更好的理解了面向对象的编程思想。
源码仓库
材料与渲染联系【可选】
Standard Shader 自然场景渲染器。
- 阅读官方 Standard Shader 手册。
- 选择合适内容,如 Albedo Color and Transparency,寻找合适素材,用博客展示相关效果的呈现
透明度
反照率颜色的 Alpha 值控制着材质的透明度级别。仅当材质的 Rendering Mode(渲染模式)设置为 Opaque 之外的 Transparent 模式之一时,此设置才有效。如上所述,选择正确的透明度模式非常重要,因为此模式可确定您是否仍然会看到处于全值状态的反射和镜面高光,或它们是否也会根据透明度值淡出。
更多介绍请看:unity官方教程
我们使用unity来测试一下透明度和反照率。
- 我们新建五个material,将其mode设置为transparent,然后Albedo设置为等比数列的形式(0,51,102,153,204,255)五种。
- 新建五个球体,将material分别赋给五个球,结果如下图所示。
以上就是 Albedo Color and Transparency 的简单使用了。
声音
阅读官方 Audio 手册
用博客给出游戏中利用 Reverb Zones 呈现车辆穿过隧道的声效的案例
首先,我们加入 Audio Reverb Zone
和 Audio Source
。
然后我们将Audio Reverb Zone
中的Reverb Preset
设置为Cave
,如下所示
最后加入声音资源即可。