模型与动画
3D游戏设计第七次作业
前言
这是中山大学2020年3D游戏设计的第七次作业,如有错误,请指正,感谢您的阅读。
游戏要求
智能巡逻兵
- 游戏设计要求:
- 创建一个地图和若干巡逻兵(使用动画);
- 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
- 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
- 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
- 失去玩家目标后,继续巡逻;
- 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
- 程序设计要求:
- 必须使用订阅与发布模式传消息
- subject:OnLostGoal
- Publisher: GameEventManager
- Subscriber: 场景控制器
- 工厂模式生产巡逻兵
游戏说明
- 游戏规则
- 每个巡逻兵都在自己的区域内巡逻,一旦玩家进入该区域,巡逻兵就会开始追逐,直至玩家离开该区域或被抓住,如果玩家离开该区域,则加一分,如果被抓住则死亡
- 为了降低游戏难度和鼓励玩家“秀”操作,当使用跳时,会让巡逻兵失去目标
- 可以使用Esc健查看详细的操作说明和退出游戏
- 不同的组合键可能会触发不同的动作
- 游戏基础操作说明
- 使用W、A、S、D移动
- 使用↑、↓、←、→来控制视角
- 使用左shift来加速
- 使用Space跳跃或翻滚
- 实现参考了师兄博客并做了改进,特此说明
人物模型
人物模型使用了师兄分享的一个模型及其基础的制作方法(师兄博客),因为它有很完备的动画库,可以完全满足本游戏的需求。但是,我只是使用了他基础的方法,下面来介绍一下我对其复刻基础上的更改。
动画器
对动画器的制作,我的思考是既然巡逻兵和玩家是不同的两个人物,那应该使用两个动画器来表现的更加直观。那么,这里我就分为了两个动画器分别对应于巡逻兵和玩家。
巡逻兵
巡逻兵的动作十分简单,只有站立,走和跑三个动作,而且都是在地面上完成的,因此状态机控制器十分简单如下所示:
而在地面上,有站立,走和跑三个动作,因此ground设计如下:
玩家
玩家的动画器就比较复杂了,因为涉及到的动作比较多,这些动作包括:
- 走、站立和跑(用于地面上)
- 后跳
- 翻滚
- 大跳
- 跳后落地
- 死亡
由于动作很多,我们一定要理清楚这些动作之间的关系,不难发现,所有的动作其实都是基于地面完成的,而大跳后会有落地动画,落地角度决定了翻滚动画,最后翻滚后又回到地面动画,也有可能跳跃后直接进入翻滚。有了这些分析,我们能首先给出一个初步的设计:
在地面上的动作和巡逻兵一致,故这里不再赘述。
不同的是,这里有多个动画进行连接,故他们之间需要创建过渡,让动画实现切换。
这里使用的参数如下所示
- forward用于ground混合树中行走奔跑的过渡,以及在不同的动作间过渡动作更加自然化
- jump用于控制跳跃和后跳
- OnGround用来指示表示模型是否在地面上,用于跳跃的衔接
- roll用于控制翻滚
- jabVelocity用于控制后跳的速度
- rollVelocity用于控制翻滚的速度
- live用于控制人物状态
有了这些参数我们就可以进行过渡的设置,由于过渡设置条件过多,本博客篇幅有限,这里仅举例说明:
- jump到ground
如果我们的参数jump被激活,那么就会从ground到jump,而jump最后结束后会重新回到地面,因此,当在地面上时,我们就可以判定jump动画进行完了
- 从ground到jab
这个过程其实是原地站立的时候按下空格进行的,因此我们得出了两个条件,需要发出跳的指令,同时需要站立状态,因此,设置如下:
- 还有一个是从jump到fall的例子,这里我们有可能碰到跳起来还没有播放下落动画就着地了的情况,因此,这里我们就设置中断源为Current State,这样就可以在过渡的起始状态中断并返回
剩下的例子与上述类似,不再赘述
脚本
脚本部分还是分为玩家和巡逻兵两个部分来做,因为两者的行为是不一样的
巡逻兵
巡逻兵其实有两个状态:巡逻和追逐,我们就根据这两个状态来进行设计。
巡逻
巡逻其实就是按照一个四边形来进行,当需要不转弯时我们设定一个目的地即可
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;
}
然后我们要判断当前位置是否需要转弯,如果需要转弯,则改变dirction
并把move_sign
置为1,即要改变走向(重新设置目的地)
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;
}
因此,我们就可以写出FixedUpdate
()函数,即当玩家没有进入时,我们就巡逻,当玩家进入,即这里data.playerSign == data.sign
并且游戏没有结束player.GetComponent<PlayerInput>().getState()
时,我们将当前的巡逻兵destroy
并重新生成一个追逐态的巡逻兵
public override void FixedUpdate() {
Gopatrol();
if (data.playerSign == data.sign&& player.GetComponent<PlayerInput>().getState()) {
this.destroy = true;
this.callback.SSActionEvent(this, SSActionEventType.Competeted, 0, this.gameobject);
}
}
追逐
追逐就比较简单了,我们就一直向着玩家的坐标移动即可,一旦玩家离开该区域,即data.playerSign != data.sign
,就要重新切换到巡逻态,但一旦玩家被抓住,那么就切换到初始态(SSActionEvent
函数稍后会讲到)
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);
}
else if (!(player.GetComponent<PlayerInput>().getState())&&restart)
{
restart = false;
this.destroy = true;
this.callback.SSActionEvent(this, SSActionEventType.Competeted, 2, this.gameobject);
}
}
状态切换
状态切换位于巡逻兵的动作控制器GuardActionManager
内,我们这里用函数SSActionEvent
来表示,通过函数我们可以看到,如果我们返回的状态为0,则创建一个追逐态的巡逻兵,如果返回的为1,则创建一个巡逻态的巡逻兵,然后将调用PlayerEscape();
改变分数,最后如果状态为2,则说明玩家已经死亡,那么巡逻兵切换到巡逻态即可,但这里不需要有分数的改变
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 if(intParam == 1){
GuardPatrolAction move = GuardPatrolAction.GetSSAction(objectParam.gameObject.GetComponent<GuardData>().start_position,player);
this.RunAction(objectParam, move, this);
Singleton<GameEventManager>.Instance.PlayerEscape();
}
else
{
GuardPatrolAction move = GuardPatrolAction.GetSSAction(objectParam.gameObject.GetComponent<GuardData>().start_position,player);
this.RunAction(objectParam, move, this);
}
}
参数设置
与运动相关的参数设置在GuardData
中,只需要将其挂载到人物上即可,由于只是一些简单的赋值,这里不再赘述
玩家
玩家的动作都是根据输入来实现的。
这里想将的有四个变动的方法
平滑变动即移动,只需要根据输入的方向做出相应移动即可。
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;
矩形坐标转圆坐标具体实现
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;
}
奔跑直接调用即可
run = Input.GetKey(keyA);
跳跃要控制不能多连跳
bool newJump = Input.GetKey(keyB);
lastJump = jump;
if(lastJump == false && newJump == true) {
jump = true;
}
else {
jump = false;
}
如果死亡,则将其他所有动作置零
if (!inputEnabled)
{
run = false;
jump = false;
lastJump = false;
targetDup = 0;
targetDright = 0;
Dmag =0;
}
当玩家与巡逻兵相撞时,我们需要调用函数来通知所有的控制器
void OnCollisionEnter(Collision other) {
if (other.gameObject.tag == "Guard") {
Singleton<GameEventManager>.Instance.PlayerGameover();
}
}
发送消息
为了让动画更加平滑,我们这里给各个动画状态也添加脚本,以便人物可以根据命令做出相应动作。
FSMOnEnter
进入状态时发送消息FSMOnExit
在状态退出时发出消息FSMOnUpdate
在状态刷新时发出消息,实现翻滚和跳跃FSMClearSignals
在状态进入和退出时清除多余的Trigger,以免跳跃多次
动作实现
由于师兄的动画实现过于完美,故我只在其基础上添加了一点改进的内容以及死亡动画,这里展示一下死亡制作的方面。
这里我在Update中添加了一个判断条件用于判断当前是否死亡,同时引入了一个布尔变量,因为如果没有该变量,人物会陷入死亡循环(一直播放死亡动画)。
if (!pi.getState()&&death)
{
death = false;
anim.SetBool("live",false);
}else if(pi.getState()){
death = true;
anim.SetBool("live", true);
}
第三人称视角
直接挂载到一个位于玩家后方的空相机上即可
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;
private GameObject camera;
private Vector3 cameraDampVelocity;
void Awake() {
cameraHandle = transform.parent.gameObject;
playerHandle = cameraHandle.transform.parent.gameObject;
model = playerHandle.GetComponent<ActorController>().model;
camera = Camera.main.gameObject;
tempEulerX = 20f;
}
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;
camera.transform.position = Vector3.SmoothDamp(
camera.transform.position, transform.position,
ref cameraDampVelocity, cameraDampValue);
camera.transform.eulerAngles = transform.eulerAngles;
}
}
代码分析
本代码依然是采用了MVC架构,与上两次作业完全相同,故这里MVC架构有关的代码不再赘述
巡逻兵工厂
首先生成每个巡逻兵的初始位置
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<GameObject>("Prefabs/Guard"));
guard.transform.position = vec[i];
guard.GetComponent<GuardData>().sign = i + 1;
guard.GetComponent<GuardData>().start_position = vec[i];
guard.GetComponent<Animator>().SetFloat("forward", 1);
used.Add(guard);
}
FirstViewController
初始化
将各个控制器初始化,用于调度
void Awake() {
SSDirector director = SSDirector.GetInstance();
director.CurrentScenceController = this;
guard_factory = Singleton<GuardFactory>.Instance;
action_manager = gameObject.AddComponent<GuardActionManager>() as GuardActionManager;
gui = gameObject.AddComponent<UserGUI>() as UserGUI;
LoadResources();
recorder = Singleton<ScoreRecorder>.Instance;
}
加载资源
加载预制文件资源,调用工厂制造多个巡逻兵,同时让巡逻兵与玩家之间建立联系
public void LoadResources() {
Instantiate(Resources.Load<GameObject>("Prefabs/Plane"));
player = Instantiate(
Resources.Load("Prefabs/Player"),
new Vector3(13, 8, -13), Quaternion.identity) as GameObject;
guards = guard_factory.GetPatrols();
for (int i = 0; i < guards.Count; i++) {
action_manager.GuardPatrol(guards[i], player);
}
}
Update
更新函数就很简单了,我们只需要判断游戏是否结束,如果没结束那么就更新玩家位置,如果结束了则告诉其它控制器游戏结束。
void Update() {
if(!game_over)
for (int i = 0; i < guards.Count; i++) {
guards[i].gameObject.GetComponent<GuardData>().playerSign = playerSign;
}
else
{
player.GetComponent<PlayerInput>().changeStateF();
}
}
UserGUI
与上次作业代码中不同的是,这次加入了一个Esc触发的窗口,窗口的创建如下:
if (windows&&Input.GetKeyDown(KeyCode.Escape))
{
windows = false;
}
if (Input.GetKeyDown(KeyCode.Escape)||windows)
{
windows = true;
GUI.Window(0, new Rect(365, 160, 600, 400), funcwin, "菜单");
}
其调用的函数funcwin如下:
[Obsolete]
private void funcwin(int id)
{
GUI.Label(new Rect(150,100,200,300), "游戏玩法:\n使用W、A、S、D移动\n使用↑、↓、←、→来控制视角\n使用左shift来加速\n使用Space跳跃或翻滚", text_style);
if (GUI.Button(new Rect(100,300,120,60), "返回"))
{
windows = false;
}
if (GUI.Button(new Rect(400, 300, 120, 60), "退出"))
{
Application.Quit();
}
}
其余UI界面都是基础内容,这里不再展示
地图
使用了师兄的地图设计,通过代码设置场景控制器中的玩家区域标志,然后场景控制器通知对应的巡逻兵追逐玩家,代码如下:
void OnTriggerEnter(Collider collider) {
if (collider.gameObject.tag == "Player") {
sceneController.playerSign = sign;
}
}
地图设计如下
天空盒与光源
天空盒依然使用了上次的TGU Skybox Pack
光源使用了如下配置:
效果展示
效果展示分为三个部分动作展示,通关展示,菜单展示
动作展示
展示了不同按键下的动作组合(由于帧数太高所以调小了图片)
通关展示
可以看到每进入一个区域相应的巡逻兵都会追逐,被碰到则会死亡
菜单展示
通过Esc调用菜单来获取玩法以及退出游戏
感悟与总结
本次作业可以说是目前为止最难的一次作业,只是人物模型的配置就需要很久很久的时间来完成,即使有了师兄的代码,也有许许多多的小bug需要解决。由于本次作业的重点是模型与动画,所以我就将以前的动画管理器进行了优化设置,将两个人物模型的动画分离。人物模型完成后,还需要将人物模型导入,创建工厂类和将其正确加载,由于Unity版本不同,许多代码以及不能适配,所以要根据自己的Unity版本进行合理的更改。
总的来说,本次实验虽然很难,但是收获也非常的多。通过学习他人代码,可以少走很多弯路,同时,我也学会了如何创建一个完整的预制体,如何利用订阅与发布模式传消息。让我对订阅与发布模式的理解更加深刻。