本文是中山大学软件工程学院2020级3d游戏编程与设计的作业7
除去老师在课堂上讲的内容,本次作业代码与操作主要参考了[傅老師/Unity教學] DarkSouls複刻經典教程#第一季_bilibili
模型动作资源也同样来自傅网课之中老师的给出教程人物模型与动画 (weiyun.com)
Unity 有一个丰富而复杂的动画系统(有时称为“Mecanim”)。该系统具有以下功能:
发布/订阅模式(Publish Subscribe Pattern)属于设计模式中的行为(Behavioral Patterns)。
在软件架构中,发布/订阅是一种消息范式,消息的发送者(称为发布者)不会将消息直接发送给特定的接收者(称为订阅者),而是通过消息通道广播出去,让订阅改消息主题的订阅者消费到。
发布/订阅者模式最大的特点就是实现了松耦合,也就是说你可以让发布者发布消息、订阅者接受消息,而不是寻找一种方式把两个分离的系统连接在一起。当然这种松耦合也是发布/订阅者模式最大的缺点,因为需要中间的代理,增加了系统的复杂度。而且发布者无法实时知道发布的消息是否被每个订阅者接收到了,增加了系统的不确定性。
智能巡逻兵
提交要求:
游戏设计要求:
创建一个地图和若千巡逻兵(使用动画);
每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
巡逻兵碰撞到障碍物,则会自动选下一个点为目标
巡逻兵在设定范围内感知到玩家,会自动追击玩家;
失去玩家目标后,继续巡逻;
计分: 玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
程序设计要求:
- 必须使用订阅与发布模式传消息
- subject: OnLostGoal
- Publisher: ?
- Subscriber: ?
- 工厂模式生产巡逻兵
友善提示1: 生成 3~5个边的凸多边型
- 随机生成矩形
- 在矩形每个边上随机找点,可得到 3 -4 的多边型
- 5?
友善提示2: 参考以前博客,给出自己新玩法
本次人物动画主要用到以下几个动作:
分别为:待机,后跳,下落,跳跃,翻滚,奔跑,行走
按照如下变化绘制状态图
动画混合树是动画状态机中的一个状态,也是多个动画的混合,可以实现多个类似动画的平滑过渡。
右键ground点击Create new BlendTree in State创建动画混合树
设置BlendTree,修改部分数值,并添加处于地面上的三个动画:待机、行走、奔跑
添加转移控制变量如下:
同时为各个变迁添加相应的控制条件变量,以地面状态到翻滚状态的变迁为例,如下:
动画状态机之中,对应的动作会引起状态机状态的改变,同时也会带来对应的变化。如果直接在状态之中挂载代码,会使得项目冗杂,难以调试。此时可以使用发布/订阅模式,从状态机的变迁之中获得相应信息进行广播,
编写了FSMClearSignals(),FSMOnEnter(),FSMOnExit(),FSMOnUpdate()
四个函数用于消息的广播与状态机的初始化
以下为FSMClearSignals()
的代码,进行初始化,如下:
public class FSMClearSignals : StateMachineBehaviour {
public string[] ClearAtEnter;
public string[] ClearAtExit;
public override void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
foreach (var signal in ClearAtEnter) {
animator.ResetTrigger(signal);
}
}
public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
foreach (var signal in ClearAtExit) {
animator.ResetTrigger(signal);
}
}
}
以下为进入状态的代码FSMOnEnter()
,在进入状态时进行广播,FSMOnExit(),FSMOnUpdate()
与此代码基本一致,此处不再放出
public class FSMOnEnter : StateMachineBehaviour {
public string[] onEnterMessages;
override public void OnStateEnter(Animator animator, AnimatorStateInfo stateInfo, int layerIndex) {
foreach (var msg in onEnterMessages) {
animator.gameObject.SendMessageUpwards(msg);
}
}
}
将代码挂载在对应状态下,此处仅以ground举例:
下载模型动画之后,为了适配本次作业,需要对一部分动作的设置进行修改,主要为Bake Into Pose选项的勾选,部分动作播放时object不需要产生旋转或位移,根据实际情况调整上面提到的七个动作即可
再次声明:
此处涉及到玩家控制输入、落地检测、第三人称实现等较为复杂的代码与操作,基本都来自于[傅老師/Unity教學] DarkSouls複刻經典教程#第一季_bilibili
按照以下层级关系制作Player对象,其中仅ybot为模型,其余均为Empty
编写CameraConrtoller()
控制摄像机,代码如下:
public class CameraConrtoller : MonoBehaviour {
public PlayerInput pi;
public float horizontalSpeed = 100f;
public float verticalSpeed = 80f;
public float cameraDampValue = 0.5f;
private GameObject playerHandle;
private GameObject cameraHandle;
private float tempEulerX;
private GameObject model;
protected GameObject mycamera;
private Vector3 cameraDampVelocity;
void Awake() {
cameraHandle = transform.parent.gameObject;
playerHandle = cameraHandle.transform.parent.gameObject;
model = playerHandle.GetComponent().model;
mycamera = Camera.main.gameObject;
tempEulerX = 20f;
}
// Update is called once per frame
void FixedUpdate() {
Vector3 tempModelEuler = model.transform.eulerAngles;
playerHandle.transform.Rotate(Vector3.up, pi.Jright * horizontalSpeed * Time.fixedDeltaTime);
tempEulerX -= pi.Jup * verticalSpeed * Time.fixedDeltaTime;
tempEulerX = Mathf.Clamp(tempEulerX, -35, 30);
cameraHandle.transform.localEulerAngles = new Vector3(tempEulerX, 0, 0);
model.transform.eulerAngles = tempModelEuler;
mycamera.transform.position = Vector3.SmoothDamp(
mycamera.transform.position, transform.position,
ref cameraDampVelocity, cameraDampValue);
mycamera.transform.eulerAngles = transform.eulerAngles;
}
}
将这一代码挂载在CameraPos上,Pi设置为Player
编写OnGroundSensor()
用于落地检测,逻辑为添加一个与人物大小相似的CapsuleCollider(胶囊碰撞体),检测该碰撞体与地面层是否发生碰撞,代码如下:
public class OnGroundSensor : MonoBehaviour
{
public CapsuleCollider capcol;
public float offset = 0.1f;
private Vector3 point1;
private Vector3 point2;
private float radius;
void Awake()
{
radius = capcol.radius;
}
void FixedUpdate()
{
point1 = transform.position + transform.up * (radius - offset);
point2 = transform.position + transform.up * (capcol.height - offset) - transform.up * radius;
Collider[] outputCols = Physics.OverlapCapsule(point1, point2, radius, LayerMask.GetMask("Ground"));
if (outputCols.Length != 0)
{
SendMessageUpwards("OnGround");
}
else
{
SendMessageUpwards("NotOnGround");
}
}
}
该代码挂载在sensor上,capcol同样设置为Player
代码如下:
public class PlayerInput : MonoBehaviour
{
[Header("---- KeyCode Settings ----")]
public string keyUp = "w";
public string keyDown = "s";
public string keyLeft = "a";
public string keyRight = "d";
public string keyA = "left shift";
public string keyB = "space";
public string keyC = "k";
public string keyD;
public string keyJUp = "up";
public string keyJDown = "down";
public string keyJLeft = "left";
public string keyJRight = "right";
[Header("---- Output Settings ----")]
public float Dup;
public float Dright;
public float Dmag;//方向
public Vector3 Dvec;//速度
public float Jup;
public float Jright;
//1.pressing signal
public bool run;
public bool jump;
private bool lastJump;
//2.trigger signal
//3.double signal
[Header("---- Other Settings ----")]
public bool inputEnabled = true;
private float targetDup;
private float targetDright;
private float velocityDup;
private float velocityDright;
void Start() { }
void Update()
{
Jup = (Input.GetKey(keyJUp)) ? 1.0f : 0 - (Input.GetKey(keyJDown) ? 1.0f : 0);
Jright = (Input.GetKey(keyJRight)) ? 1.0f : 0 - (Input.GetKey(keyJLeft) ? 1.0f : 0);
targetDup = (Input.GetKey(keyUp) ? 1.0f : 0) - (Input.GetKey(keyDown) ? 1.0f : 0);
targetDright = (Input.GetKey(keyRight) ? 1.0f : 0) - (Input.GetKey(keyLeft) ? 1.0f : 0);
if (!inputEnabled)
{
targetDup = 0;
targetDright = 0;
}
//平滑变动
Dup = Mathf.SmoothDamp(Dup, targetDup, ref velocityDup, 0.1f);
Dright = Mathf.SmoothDamp(Dright, targetDright, ref velocityDright, 0.1f);
/*矩形坐标转圆坐标*/
Vector2 tempDAxis = SquareToCircle(new Vector2(Dup, Dright));
float Dup2 = tempDAxis.x;
float Dright2 = tempDAxis.y;
Dmag = Mathf.Sqrt((Dup2 * Dup2) + (Dright2 * Dright2));
Dvec = Dright * transform.right + Dup * transform.forward;
run = Input.GetKey(keyA);
/*跳跃*/
bool newJump = Input.GetKey(keyB);
lastJump = jump;
if (lastJump == false && newJump == true)
{
jump = true;
}
else
{
jump = false;
}
}
/*矩形坐标转圆坐标*/
private Vector2 SquareToCircle(Vector2 input)
{
Vector2 output = Vector2.zero;
output.x = input.x * Mathf.Sqrt(1 - (input.y * input.y) / 2.0f);
output.y = input.y * Mathf.Sqrt(1 - (input.x * input.x) / 2.0f);
return output;
}
}
代码如下:
public class ActorController : MonoBehaviour
{
public GameObject model;
public PlayerInput pi;
public float walkSpeed = 1.5f;
public float runMultiplier = 2.7f;
public float jumpVelocity = 4f;
public float rollVelocity = 1f;
[SerializeField]
private Animator anim;
private Rigidbody rigid;
private Vector3 planarVec; // 平面移动向量
private Vector3 thrustVec; // 跳跃冲量
private bool lockPlanar = false; // 跳跃时锁死平面移动向量
void Awake()
{
pi = GetComponent();
anim = model.GetComponent();
rigid = GetComponent();
}
//刷新每秒60次
void Update()
{
//修改动画混合树
/*1.从走路到跑步没有过渡*/
/*anim.SetFloat("forward", pi.Dmag * (pi.run ? 2.0f : 1.0f));*/
/*2.使用Lerp加权平均解决*/
float targetRunMulti = pi.run ? 2.0f : 1.0f;
anim.SetFloat("forward", pi.Dmag * Mathf.Lerp(anim.GetFloat("forward"), targetRunMulti, 0.3f));
//播放翻滚动画
if (rigid.velocity.magnitude > 1.0f)
{
anim.SetTrigger("roll");
}
//播放跳跃动画
if (pi.jump)
{
anim.SetTrigger("jump");
}
//转向
if (pi.Dmag > 0.01f)
{
/*1.旋转太快没有补帧*/
/*model.transform.forward = pi.Dvec;*/
/*2.使用Slerp内插值解决*/
Vector3 targetForward = Vector3.Slerp(model.transform.forward, pi.Dvec, 0.2f);
model.transform.forward = targetForward;
}
if (!lockPlanar)
{
//保存供物理引擎使用
planarVec = pi.Dmag * model.transform.forward * walkSpeed * (pi.run ? runMultiplier : 1.0f);
}
}
//物理引擎每秒50次
private void FixedUpdate()
{
//Time.fixedDeltaTime 50/s
//1.修改位置
//rigid.position += movingVec * Time.fixedDeltaTime;
//2.修改速度
rigid.velocity = new Vector3(planarVec.x, rigid.velocity.y, planarVec.z) + thrustVec;
//一帧
thrustVec = Vector3.zero;
}
public void OnJumpEnter()
{
pi.inputEnabled = false;
lockPlanar = true;
thrustVec = new Vector3(0, jumpVelocity, 0);
}
public void OnRollEnter()
{
pi.inputEnabled = false;
lockPlanar = true;
}
public void OnRollUpdate()
{
thrustVec = model.transform.forward * anim.GetFloat("rollVelocity") * 1.0f;
}
public void OnGround()
{
anim.SetBool("OnGround", true);
}
public void NotOnGround()
{
anim.SetBool("OnGround", false);
}
public void OnGroundEnter()
{
pi.inputEnabled = true;
lockPlanar = false;
}
public void OnFallEnter()
{
pi.inputEnabled = false;
lockPlanar = true;
}
public void OnJabEnter()
{
pi.inputEnabled = false;
lockPlanar = true;
}
public void OnJabUpdate()
{
thrustVec = model.transform.forward * anim.GetFloat("jabVelocity") * 1.4f;
}
}
代码如下:
public class PlayerCollide : MonoBehaviour {
void OnCollisionEnter(Collision other) {
//当玩家与侦察兵相撞
if (other.gameObject.tag == "Guard") {
Singleton.Instance.PlayerGameover();
}
}
}
上述三处代码均挂载在Player之上,同时需要给Player添加刚体组件与胶囊碰撞体,如下:
同时需要给ybot的Animator组件添加之前制作的Animator Controller,如下:
按照以下层级关系制作Guard对象,其中仅ybot为模型,Guard父对象为为Empty
代码如下,主要为给巡逻兵实体设置后续所需的数据:
public class GuardData : MonoBehaviour {
public GameObject model;
public float walkSpeed = 1.2f;
public float runSpeed = 2.5f;
public int sign; //标志巡逻兵在哪一块区域
public bool isFollow = false; //是否跟随玩家
public int playerSign = -1; //当前玩家所在区域标志
public Vector3 start_position; //当前巡逻兵初始位置
[SerializeField]
private Animator anim;
private Rigidbody rigid;
void Awake() {
anim = model.GetComponent();
rigid = GetComponent();
}
public void OnGround() {
anim.SetBool("OnGround", true);
}
public void OnGroundEnter() {
}
}
上述代码需要挂载在Guard之上,同时需要给Guard添加刚体组件与胶囊碰撞体,如下:
注:此处ybot的Animator组件无需再添加之前制作的Animator Controller
Plane为本次项目中玩家与巡逻兵行动的平台,预制如下:
其中Plane父对象,Sensor,Trigger均为Empty,Plane子对象为地面,Wall为不同的前面
在Player与地面或墙面碰撞之后,传出Player所处区域,代码如下:
public class AreaCollide : MonoBehaviour {
public int sign = 0;
private FirstSceneController sceneController;
private void Start() {
sceneController = SSDirector.GetInstance().CurrentScenceController as FirstSceneController;
}
void OnTriggerEnter(Collider collider) {
if (collider.gameObject.tag == "Player") {
sceneController.playerSign = sign;
}
}
}
在建立Plane父对象时,需要给Plane设置Layer为Ground,才可以实现Player中的落地监测函数
需要给各个Triger设置相应大小的碰撞体,挂载AreaCollide()
函数
和之前作业中的动作分离基本一致,包括了SSAction()
动作基类,SSActionManager()
动作管理类,这里就不再放出了
实现巡逻兵的动作管理类为GuardActionManager()
,实现了巡逻与追逐玩家两个动作,代码如下:
public class GuardActionManager : SSActionManager, ISSActionCallback {
private GuardPatrolAction patrol;
private GameObject player;
public void GuardPatrol(GameObject guard, GameObject _player) {
player = _player;
patrol = GuardPatrolAction.GetSSAction(guard.transform.position);
this.RunAction(guard, patrol, this);
}
public void SSActionEvent(
SSAction source, SSActionEventType events = SSActionEventType.Competeted,
int intParam = 0, GameObject objectParam = null) {
if (intParam == 0) {
//追逐
GuardFollowAction follow = GuardFollowAction.GetSSAction(player);
this.RunAction(objectParam, follow, this);
} else {
//巡逻
GuardPatrolAction move = GuardPatrolAction.GetSSAction(objectParam.gameObject.GetComponent().start_position);
this.RunAction(objectParam, move, this);
Singleton.Instance.PlayerEscape();
}
}
}
使用工厂模式批量生成巡逻兵,由于已经在鼠标打飞碟那一节课程学习过了,此处就不再介绍代码实现,仅放出代码如下:
public class GuardFactory : MonoBehaviour {
private GameObject guard = null; //巡逻兵
private List used = new List(); //正在使用的巡逻兵列表
private Vector3[] vec = new Vector3[9]; //每个巡逻兵的初始位置
public List GetPatrols() {
int[] pos_x = { -6, 4, 13 };
int[] pos_z = { -4, 6, -13 };
int index = 0;
for(int i=0;i < 3;i++) {
for(int j=0;j < 3;j++) {
vec[index] = new Vector3(pos_x[i], 0, pos_z[j]);
index++;
}
}
for(int i = 0; i < 8; i++) {
guard = Instantiate(Resources.Load("Prefabs/Guard"));
guard.transform.position = vec[i];
guard.GetComponent().sign = i + 1;
guard.GetComponent().start_position = vec[i];
guard.GetComponent().SetFloat("forward", 1);
used.Add(guard);
}
return used;
}
}
分成了两个部分,巡逻与追逐
巡逻即在一个矩形范围内重复移动,巡逻代码如下:
public class GuardPatrolAction : SSAction {
private enum Dirction { EAST, NORTH, WEST, SOUTH };
private float pos_x, pos_z;
private float move_length;
private bool move_sign = true;
private Dirction dirction = Dirction.EAST;
private GuardData data;
private Animator anim;
private Rigidbody rigid;
private Vector3 planarVec; // 平面移动向量
private GuardPatrolAction() { }
public override void Start() {
data = gameobject.GetComponent();
anim = gameobject.GetComponent();
rigid = gameobject.GetComponent();
//播放走路动画
anim.SetFloat("forward", 1.0f);
}
public static GuardPatrolAction GetSSAction(Vector3 location) {
GuardPatrolAction action = CreateInstance();
action.pos_x = location.x;
action.pos_z = location.z;
//设定移动矩形的边长
action.move_length = Random.Range(5, 6);
return action;
}
public override void Update() {
//保留供物理引擎调用
planarVec = gameobject.transform.forward * data.walkSpeed;
}
public override void FixedUpdate() {
//巡逻
Gopatrol();
//玩家进入该区域,巡逻结束,开始追逐
if (data.playerSign == data.sign) {
this.destroy = true;
this.callback.SSActionEvent(this, SSActionEventType.Competeted, 0, this.gameobject);
}
}
void Gopatrol() {
if (move_sign) {
//不需要转向则设定一个目的地,按照矩形移动
switch (dirction) {
case Dirction.EAST:
pos_x -= move_length;
break;
case Dirction.NORTH:
pos_z += move_length;
break;
case Dirction.WEST:
pos_x += move_length;
break;
case Dirction.SOUTH:
pos_z -= move_length;
break;
}
move_sign = false;
}
this.transform.LookAt(new Vector3(pos_x, 0, pos_z));
float distance = Vector3.Distance(transform.position, new Vector3(pos_x, 0, pos_z));
if (distance > 0.9) {
rigid.velocity = new Vector3(planarVec.x, rigid.velocity.y, planarVec.z);
} else {
dirction = dirction + 1;
if(dirction > Dirction.SOUTH) {
dirction = Dirction.EAST;
}
move_sign = true;
}
}
}
追逐发生在玩家进入巡逻区域之后,玩家进入之后会向着玩家方向前进,当玩家离开巡逻范围则进行巡逻
public class GuardFollowAction : SSAction {
private GameObject player;
private GuardData data;
private Animator anim;
private Rigidbody rigid;
private Vector3 planarVec; // 平面移动向量
private float speed;
private GuardFollowAction() {}
public override void Start() {
data = gameobject.GetComponent();
anim = gameobject.GetComponent();
rigid = gameobject.GetComponent();
speed = data.runSpeed;
anim.SetFloat("forward", 2.0f);
}
public static GuardFollowAction GetSSAction(GameObject player) {
GuardFollowAction action = CreateInstance();
action.player = player;
return action;
}
public override void Update() {
//保留供物理引擎调用
planarVec = gameobject.transform.forward * speed;
}
public override void FixedUpdate() {
transform.LookAt(player.transform.position);
rigid.velocity = new Vector3(planarVec.x, rigid.velocity.y, planarVec.z);
//如果玩家脱离该区域则继续巡逻
if (data.playerSign != data.sign) {
this.destroy = true;
this.callback.SSActionEvent(this, SSActionEventType.Competeted, 1, this.gameobject);
}
}
}
编写了GameEventManager()
进行全局性的发布消息,接受者部分消息方则实现在FirstSceneController()
public class GameEventManager : MonoBehaviour {
public delegate void ScoreEvent();
public static event ScoreEvent ScoreChange;
public delegate void GameoverEvent();
public static event GameoverEvent GameoverChange;
public void PlayerEscape() {
if (ScoreChange != null) {
ScoreChange();
}
}
public void PlayerGameover(){
if (GameoverChange != null) {
GameoverChange();
}
}
}
项目其他代码如:SSDirector(),Singleton(),UserGUI(),Interface()
等就不再放出,请移步文章最后到项目之中查看
本次由于录制视频较大,就不再制作gif图。演示视频已经投稿至b站智能巡逻兵-哔哩哔哩-bilibili
代码以及文档均已经上传至hw7 · XiaoChen04_3/3D_Computer_Game_Programming - gitee
动画系统概述 - Unity 手册 ↩︎
设计模式之发布订阅模式(1) 一文搞懂发布订阅模式 - 腾讯云开发者社区-腾讯云 (tencent.com) ↩︎