Unity模型与动画

3D游戏设计第七次作业

前言

这是中山大学2020年3D游戏设计的第七次作业,如有错误,请指正,感谢您的阅读。

游戏要求

智能巡逻兵

  • 游戏设计要求:
    • 创建一个地图和若干巡逻兵(使用动画);
    • 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;
    • 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;
    • 巡逻兵在设定范围内感知到玩家,会自动追击玩家;
    • 失去玩家目标后,继续巡逻;
    • 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;
  • 程序设计要求:
    • 必须使用订阅与发布模式传消息
    • subject:OnLostGoal
    • Publisher: GameEventManager
    • Subscriber: 场景控制器
    • 工厂模式生产巡逻兵

游戏说明

  • 游戏规则
    • 每个巡逻兵都在自己的区域内巡逻,一旦玩家进入该区域,巡逻兵就会开始追逐,直至玩家离开该区域或被抓住,如果玩家离开该区域,则加一分,如果被抓住则死亡
    • 为了降低游戏难度和鼓励玩家“秀”操作,当使用跳时,会让巡逻兵失去目标
    • 可以使用Esc健查看详细的操作说明和退出游戏
    • 不同的组合键可能会触发不同的动作
  • 游戏基础操作说明
    • 使用W、A、S、D移动
    • 使用↑、↓、←、→来控制视角
    • 使用左shift来加速
    • 使用Space跳跃或翻滚
  • 实现参考了师兄博客并做了改进,特此说明

人物模型

人物模型使用了师兄分享的一个模型及其基础的制作方法(师兄博客),因为它有很完备的动画库,可以完全满足本游戏的需求。但是,我只是使用了他基础的方法,下面来介绍一下我对其复刻基础上的更改。

动画器

对动画器的制作,我的思考是既然巡逻兵和玩家是不同的两个人物,那应该使用两个动画器来表现的更加直观。那么,这里我就分为了两个动画器分别对应于巡逻兵和玩家。

巡逻兵

巡逻兵的动作十分简单,只有站立,走和跑三个动作,而且都是在地面上完成的,因此状态机控制器十分简单如下所示:
G
而在地面上,有站立,走和跑三个动作,因此ground设计如下:
ground

玩家

玩家的动画器就比较复杂了,因为涉及到的动作比较多,这些动作包括:

  • 走、站立和跑(用于地面上)
  • 后跳
  • 翻滚
  • 大跳
  • 跳后落地
  • 死亡
    由于动作很多,我们一定要理清楚这些动作之间的关系,不难发现,所有的动作其实都是基于地面完成的,而大跳后会有落地动画,落地角度决定了翻滚动画,最后翻滚后又回到地面动画,也有可能跳跃后直接进入翻滚。有了这些分析,我们能首先给出一个初步的设计:
    Player

在地面上的动作和巡逻兵一致,故这里不再赘述。
不同的是,这里有多个动画进行连接,故他们之间需要创建过渡,让动画实现切换。
这里使用的参数如下所示
value

  • forward用于ground混合树中行走奔跑的过渡,以及在不同的动作间过渡动作更加自然化
  • jump用于控制跳跃和后跳
  • OnGround用来指示表示模型是否在地面上,用于跳跃的衔接
  • roll用于控制翻滚
  • jabVelocity用于控制后跳的速度
  • rollVelocity用于控制翻滚的速度
  • live用于控制人物状态

有了这些参数我们就可以进行过渡的设置,由于过渡设置条件过多,本博客篇幅有限,这里仅举例说明:

  • jump到ground
    如果我们的参数jump被激活,那么就会从ground到jump,而jump最后结束后会重新回到地面,因此,当在地面上时,我们就可以判定jump动画进行完了
    jump
  • 从ground到jab
    这个过程其实是原地站立的时候按下空格进行的,因此我们得出了两个条件,需要发出跳的指令,同时需要站立状态,因此,设置如下:
    jab
  • 还有一个是从jump到fall的例子,这里我们有可能碰到跳起来还没有播放下落动画就着地了的情况,因此,这里我们就设置中断源为Current State,这样就可以在过渡的起始状态中断并返回
    fall

剩下的例子与上述类似,不再赘述

脚本

脚本部分还是分为玩家和巡逻兵两个部分来做,因为两者的行为是不一样的

巡逻兵

巡逻兵其实有两个状态:巡逻和追逐,我们就根据这两个状态来进行设计。

巡逻

巡逻其实就是按照一个四边形来进行,当需要不转弯时我们设定一个目的地即可

        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;
        }
    }

地图设计如下
map

天空盒与光源

天空盒依然使用了上次的TGU Skybox Pack
光源使用了如下配置:
light

效果展示

效果展示分为三个部分动作展示,通关展示,菜单展示

动作展示

展示了不同按键下的动作组合(由于帧数太高所以调小了图片)
动作

通关展示

可以看到每进入一个区域相应的巡逻兵都会追逐,被碰到则会死亡
通关

菜单展示

通过Esc调用菜单来获取玩法以及退出游戏
菜单

感悟与总结

本次作业可以说是目前为止最难的一次作业,只是人物模型的配置就需要很久很久的时间来完成,即使有了师兄的代码,也有许许多多的小bug需要解决。由于本次作业的重点是模型与动画,所以我就将以前的动画管理器进行了优化设置,将两个人物模型的动画分离。人物模型完成后,还需要将人物模型导入,创建工厂类和将其正确加载,由于Unity版本不同,许多代码以及不能适配,所以要根据自己的Unity版本进行合理的更改。
总的来说,本次实验虽然很难,但是收获也非常的多。通过学习他人代码,可以少走很多弯路,同时,我也学会了如何创建一个完整的预制体,如何利用订阅与发布模式传消息。让我对订阅与发布模式的理解更加深刻。

代码仓库

智能巡逻兵

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值