Unity 3D 巡逻兵

要求:

  • 创建一个地图和若干巡逻兵(使用动画);

  • 每个巡逻兵走一个3~5个边的凸多边型,位置数据是相对地址。即每次确定下一个目标位置,用自己当前位置为原点计算;

  • 巡逻兵碰撞到障碍物,则会自动选下一个点为目标;

  • 巡逻兵在设定范围内感知到玩家,会自动追击玩家;

  • 失去玩家目标后,继续巡逻;

  • 计分:玩家每次甩掉一个巡逻兵计一分,与巡逻兵碰撞游戏结束;

说明:

新建一个空对象,将 FirstSceneController.cs , PatrolFactory.cs , UI.cs 挂载到该对象上即可运行。

使用WSAD或者方向键控制方向,WS控制前进后退,AD控制向左右转向。空格键加速。

若WSAD无法正常工作,切换键盘为大写英文或者使用方向键控制即可。

游戏里共有三类对象,分别为玩家,巡逻兵,和地板。玩家我是在Asset Store里找的资源,巡逻兵为老师给的Garen,地板即是简单Cube与Plane的组合,其实还贴了墙纸,只不过摄像机看不清纹路。

效果图:

效果图

实现:

  1. Action

    接口,声明玩家的动作:

    1
    2
    3
    4
    5
    6
    public interface Action
    {
    void move(float x, float z);//移动玩家
    void gameOver();//游戏结束
    void changeScore();//加分
    }

  2. Director

    导演类,使用单例模式实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    public class Director : System.Object
    {
    private static Director _instance;
    private bool isEnd = false;//标志游戏是否结束
    public SceneController currentSceneController { get; set; }
    public static Director getInstance()
    {
    if (_instance == null)
    {
    _instance = new Director();
    }
    return _instance;
    }
    public bool getState()
    {
    return _instance.isEnd;
    }
    public void end()
    {
    _instance.isEnd = true;
    }
    public void reset()//重新开始游戏
    {
    _instance.isEnd = false;
    }
    public int getFPS()
    {
    return Application.targetFrameRate;
    }
    public void setFPS(int fps)
    {
    Application.targetFrameRate = fps;
    }
    }
  3. ScoreRecorder

    记分员,同样采用单例模式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    public class ScoreRecorder:System.Object//记分员
    {
    private int score = 0;

    private static ScoreRecorder instance;
    public static ScoreRecorder getInstance()
    {
    if(instance == null)
    {
    instance = new ScoreRecorder();
    }
    return instance;
    }
    public void record()//加分
    {
    instance.score++;
    }
    public int getScore()
    {
    return instance.score;
    }
    public void reset()
    {
    instance.score = 0;
    }
    }
  4. PatrolFactory

    工厂模式,由于此次不牵扯回收问题,所以只用写创造巡逻兵即可:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    public class PatrolFactory : MonoBehaviour {

    List<GameObject> list = new List<GameObject>();
    public List<GameObject> getPatrolmans()
    {
    int[] x = new int[2] { -10, 5 };
    int[] z = new int[3] { -5, 0, 5 };
    Vector3[] location = new Vector3[9];
    int count = 0;
    for(int i = 0;i < 2; i++)
    {
    for(int j = 0;j < 3; j++)
    {
    GameObject patrolman = Instantiate(Resources.Load("Prefabs/Patrolman"), new Vector3(0, 0, 7), Quaternion.identity) as GameObject;
    location[count] = new Vector3(x[i], 0, z[j]);
    patrolman.transform.position = location[count];
    patrolman.GetComponent<Animator>().SetBool("run", true);//默认巡逻兵为移动
    list.Add(patrolman);
    count++;
    }
    }
    return list;
    }

    public void removeRigid()//结束后调用,移除刚体,以免对象之间继续碰撞
    {
    for(int i = 0;i < list.Count; i++)
    {
    list[i].GetComponent<Animator>().SetBool("run", false);
    list[i].GetComponent<Animator>().SetBool("idle", true);
    Destroy(list[i].GetComponent<Rigidbody>());
    }
    }
    }
  5. PatrolmanController

    负责控制巡逻兵的逻辑部分,需将其绑定到巡逻兵预制上。

    我认为本次作业的难点有一大半出在此处,关键在于如何实现碰撞和如何处理碰撞。

    关于实现碰撞:

    • 首先,给巡逻兵,墙体和玩家添加刚体,并且不使用重力,给墙体选中 is Kinematic 属性,使其不会移动。这是碰撞产生的先决条件。
    • 其次,给巡逻兵,墙体和玩家添加碰撞器,设置好大小,尤其是玩家和巡逻兵,使用 Capsule Collider 直径以刚好包住对象为宜。
    • 最后,给巡逻兵添加触发器,即添加 Box Collider ,半径可设置稍微大一点,选中 isTrigger ,这样就可以啦。

    以下是关于碰撞及触发逻辑的处理,需要注意的是,若巡逻兵在追逐玩家的过程中,玩家跑到了墙后面,或者说玩家与巡逻兵之间仅一墙之隔,巡逻兵也需优先处理碰撞到墙的情况,即碰墙或巡逻兵的优先级高于碰玩家。

    这样处理的好处是不会出现隔山打牛的情况,或者巡逻兵穿墙而入,并且代码量不多。

    最后,当巡逻兵追逐玩家时,速度会加快,远离之后速度恢复正常。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    void OnCollisionEnter(Collision collision)//碰撞
    {
    if(collision.gameObject.tag == "patrolman")
    {
    canFollow = false;//添加变量,当与其他巡逻兵或者墙体碰撞时,优先处理

    direction = (direction + 1) % 4;
    setNewPath();
    }
    else if(collision.gameObject.tag == "wall")
    {
    canFollow = false;
    direction = (direction + 1) % 4;
    setNewPath();
    }
    if (collision.gameObject.tag == "player" && !dir.getState()&&canFollow)
    {
    if (hit != null)
    {
    this.GetComponent<Animator>().SetBool("attack", true);
    dir.end();
    hit();
    }
    }
    }
    void OnCollisionExit(Collision collision)//退出碰撞
    {
    if(collision.gameObject.tag == "patrolman"|| collision.gameObject.tag == "wall")
    {
    canFollow = true;
    }
    }

    void OnTriggerEnter(Collider other)//进入触发器范围
    {
    if(other.transform.tag == "player" && !dir.getState() && canFollow)
    {
    target = other.transform.position;
    this.transform.LookAt(other.transform.position);
    speed *= 2;
    }
    }

    void OnTriggerExit(Collider other)//退出触发器范围
    {
    if (other.transform.tag == "player" && !dir.getState() && canFollow)
    {
    if (scoreRecord != null)
    {
    scoreRecord();
    }
    setNewPath();
    speed /= 2;
    }
    }

    void setNewPath()
    {
    System.Random ran = new System.Random();
    length = ran.Next(5, 6);
    x = this.transform.position.x;
    z = this.transform.position.z;
    if (direction == 0)
    {
    x -= length;
    }
    else if (direction == 1)
    {
    z += length;
    }
    else if (direction == 2)
    {
    x += length;
    }
    else if (direction == 3)
    {
    z -= length;
    }
    if(x < -15)//若超出地图,则设置边缘
    {
    x = -14;
    }
    if(x > 15)
    {
    x = 14;
    }
    if(z < -15)
    {
    z = -14;
    }
    if(z > 15)
    {
    z = 14;
    }
    target = new Vector3(x, 0, z);
    this.transform.LookAt(target);
    }
  6. FirstSceneController

    场景控制器,继承 ActionSceneController 接口,实现相应函数。

    本次游戏的另一个重点,订阅与发布模式也在此实现。

    FirstSceneController 订阅了 PatrolmanController 发布的事件,并且做出相应的动作.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void Enable()//订阅事件
    {
    PatrolmanController.hit += gameOver;
    PatrolmanController.scoreRecord += changeScore;
    }
    void Disable()//取消订阅
    {
    PatrolmanController.hit -= gameOver;
    PatrolmanController.scoreRecord -= changeScore;;
    }

    实现接口:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    public void LoadResources()
    {
    director = Director.getInstance();
    director.currentSceneController = this;

    }
    public void move(float x, float z) {
    if (!director.getState())
    {

    player.GetComponent<Animator>().SetBool("run", true);
    player.transform.Translate(0, 0, z * Time.deltaTime);
    player.transform.Rotate(0, x * rotate_speed * 3 * Time.deltaTime, 0);
    if (player.transform.position.y != 0)//确保y为0
    {
    player.transform.position = new Vector3(player.transform.position.x, 0, player.transform.position.z);
    }
    if (player.transform.localEulerAngles.x != 0 || player.transform.localEulerAngles.z != 0)//确保只能左右旋转
    {
    player.transform.localEulerAngles = new Vector3(0, player.transform.localEulerAngles.y, 0);
    }
    }
    }

    public void gameOver() {
    director.end();
    player.GetComponent<Animator>().SetBool("run", false);
    player.GetComponent<Animator>().SetBool("idle", true);
    Destroy(player.GetComponent<Rigidbody>());
    factory.removeRigid();
    Disable();
    }
    public void changeScore()
    {
    score.record();
    }
  7. UI

    实现了基本的计时器,计分和重新开始选项。

    由于巡逻兵追逐速度加快,并且触发器的范围并不是很大,所以容易造成玩家基本不可能逃出巡逻兵的魔爪。所以我设置了按下 Space 键加速的功能,方便逃出追捕范围,容易得分,可以多次在死亡的边缘试探。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    void Update()
    {
    action = Director.getInstance().currentSceneController as Action;
    float offsetX = Input.GetAxis("Horizontal");
    float offsetZ = Input.GetAxis("Vertical");
    action.move(offsetX * rate, offsetZ * rate);

    if (Input.GetKeyDown(KeyCode.Space))
    {
    rate *= 3;
    }
    if (Input.GetKeyUp(KeyCode.Space))
    {
    rate = 3;
    }
    if (flag == 1)
    {
    timer += Time.deltaTime;
    if (timer >= 1f)
    {
    second++;
    timer = 0;
    }
    if (second >= 60)
    {
    minute++;
    second = 0;
    }
    if (minute >= 60)
    {
    minute = 0;
    }
    }
    }

    void OnGUI()
    {
    str = string.Format("{0:00}:{1:00}", minute, second);//计时器
    str = "Time: " + str;
    GUIStyle style = new GUIStyle();
    style.fontSize = 20;
    GUI.Label(new Rect(520, 0, 100, 200), str, style);

    int score = s.getScore();//记分
    string ss = "Score: " + score.ToString();
    GUI.Label(new Rect(0, 0, 100, 200), ss, style);

    if (director.getState() == true)
    {
    flag = 0;
    if (GUI.Button(new Rect(280, 130, 100, 50), "RESET"))
    {
    flag = 1;
    timer = 0;
    minute = 0;
    second = 0;
    director.reset();
    s.reset();
    SceneManager.LoadScene("Scene");
    }
    }

    }

总结:

本次作业还是有些难的,不过收获也颇多,除了订阅与发布模式,动画系统、碰撞器、触发器等部件的特性也有了很多了解。上课跟着老师做,跟自己独立完成还是有很大区别的,还是要勤动手啊。

就是过程有点痛苦。

查看代码点这里 ,附上演示视频

分享到 评论